Add rules language support and improve password validation across the app
This commit is contained in:
@@ -3,3 +3,4 @@
|
|||||||
/penaltytracker.db-shm
|
/penaltytracker.db-shm
|
||||||
/penaltytracker.db-wal
|
/penaltytracker.db-wal
|
||||||
/config.json
|
/config.json
|
||||||
|
/penaltytracker
|
||||||
|
|||||||
Generated
-7
@@ -3,13 +3,6 @@
|
|||||||
<component name="CsvFileAttributes">
|
<component name="CsvFileAttributes">
|
||||||
<option name="attributeMap">
|
<option name="attributeMap">
|
||||||
<map>
|
<map>
|
||||||
<entry key="/README.md">
|
|
||||||
<value>
|
|
||||||
<Attribute>
|
|
||||||
<option name="separator" value="," />
|
|
||||||
</Attribute>
|
|
||||||
</value>
|
|
||||||
</entry>
|
|
||||||
<entry key="/rules/de.csv">
|
<entry key="/rules/de.csv">
|
||||||
<value>
|
<value>
|
||||||
<Attribute>
|
<Attribute>
|
||||||
|
|||||||
Generated
+11
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="db-forest-configuration">
|
||||||
|
<data version="2">.
|
||||||
|
----------------------------------------
|
||||||
|
1:0:e0f49905-9df6-459a-a57c-731edb2c1607
|
||||||
|
2:0:74720f71-b717-4c46-a783-e93fc40a8785
|
||||||
|
3:0:c2ae7de6-543e-4eed-8b31-a13cb00693a8
|
||||||
|
.</data>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -25,8 +26,21 @@ const sessionDuration = 24 * time.Hour * 14
|
|||||||
const maxUsernameLen = 64
|
const maxUsernameLen = 64
|
||||||
const maxDisplayNameLen = 128
|
const maxDisplayNameLen = 128
|
||||||
const maxPasswordLen = 256
|
const maxPasswordLen = 256
|
||||||
|
const minPasswordLen = 8
|
||||||
const maxFieldLen = 2000
|
const maxFieldLen = 2000
|
||||||
|
|
||||||
|
// validatePassword returns "" if the password meets the policy, or a short
|
||||||
|
// error code suitable for the JSON `error` field if it doesn't.
|
||||||
|
func validatePassword(p string) string {
|
||||||
|
if len(p) < minPasswordLen {
|
||||||
|
return "password_too_short"
|
||||||
|
}
|
||||||
|
if len(p) > maxPasswordLen {
|
||||||
|
return "too_long"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func registerAuthRoutes(mux *http.ServeMux) {
|
func registerAuthRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("POST /api/login", loginRateLimit(handleLogin))
|
mux.HandleFunc("POST /api/login", loginRateLimit(handleLogin))
|
||||||
mux.HandleFunc("POST /api/logout", handleLogout)
|
mux.HandleFunc("POST /api/logout", handleLogout)
|
||||||
@@ -87,7 +101,9 @@ func clientIP(r *http.Request) string {
|
|||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
func loginAllowed(ip string) bool {
|
// loginStatus returns (remaining, retryAfterSeconds). When remaining == 0 the
|
||||||
|
// caller must reject the request and use retryAfterSeconds for headers.
|
||||||
|
func loginStatus(ip string) (int, int) {
|
||||||
loginMu.Lock()
|
loginMu.Lock()
|
||||||
defer loginMu.Unlock()
|
defer loginMu.Unlock()
|
||||||
a, ok := loginAttempts_[ip]
|
a, ok := loginAttempts_[ip]
|
||||||
@@ -103,7 +119,20 @@ func loginAllowed(ip string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.times = kept
|
a.times = kept
|
||||||
return len(a.times) < loginMaxAttempts
|
remaining := loginMaxAttempts - len(a.times)
|
||||||
|
if remaining < 0 {
|
||||||
|
remaining = 0
|
||||||
|
}
|
||||||
|
retryAfter := 0
|
||||||
|
if remaining == 0 && len(a.times) > 0 {
|
||||||
|
// Time until the oldest attempt falls out of the window.
|
||||||
|
next := a.times[0].Add(loginWindow).Sub(time.Now())
|
||||||
|
retryAfter = int(next.Seconds()) + 1
|
||||||
|
if retryAfter < 1 {
|
||||||
|
retryAfter = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return remaining, retryAfter
|
||||||
}
|
}
|
||||||
|
|
||||||
func loginRecord(ip string) {
|
func loginRecord(ip string) {
|
||||||
@@ -120,7 +149,12 @@ func loginRecord(ip string) {
|
|||||||
func loginRateLimit(h http.HandlerFunc) http.HandlerFunc {
|
func loginRateLimit(h http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ip := clientIP(r)
|
ip := clientIP(r)
|
||||||
if !loginAllowed(ip) {
|
remaining, retryAfter := loginStatus(ip)
|
||||||
|
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(loginMaxAttempts))
|
||||||
|
w.Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
|
||||||
|
if remaining == 0 {
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
|
||||||
|
w.Header().Set("X-RateLimit-Reset", strconv.Itoa(retryAfter))
|
||||||
writeError(w, http.StatusTooManyRequests, "too_many_attempts")
|
writeError(w, http.StatusTooManyRequests, "too_many_attempts")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -245,8 +279,8 @@ func handleUpdateMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.Password != nil && *req.Password != "" {
|
if req.Password != nil && *req.Password != "" {
|
||||||
if len(*req.Password) > maxPasswordLen {
|
if msg := validatePassword(*req.Password); msg != "" {
|
||||||
writeError(w, http.StatusBadRequest, "too_long")
|
writeError(w, http.StatusBadRequest, msg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
|
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
|
||||||
|
|||||||
+27
-8
@@ -43,9 +43,9 @@ func handleListCompetitions(w http.ResponseWriter, r *http.Request) {
|
|||||||
var rows *sql.Rows
|
var rows *sql.Rows
|
||||||
var err error
|
var err error
|
||||||
if u.IsSystemAdmin {
|
if u.IsSystemAdmin {
|
||||||
rows, err = db.Query("SELECT id,name,allow_any_scorer_edit,created_at FROM competitions ORDER BY created_at DESC")
|
rows, err = db.Query("SELECT id,name,allow_any_scorer_edit,rules_language,created_at FROM competitions ORDER BY created_at DESC")
|
||||||
} else {
|
} else {
|
||||||
rows, err = db.Query(`SELECT c.id,c.name,c.allow_any_scorer_edit,c.created_at,cu.role
|
rows, err = db.Query(`SELECT c.id,c.name,c.allow_any_scorer_edit,c.rules_language,c.created_at,cu.role
|
||||||
FROM competitions c JOIN competition_users cu ON cu.competition_id=c.id
|
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)
|
WHERE cu.user_id=? ORDER BY c.created_at DESC`, u.ID)
|
||||||
}
|
}
|
||||||
@@ -59,10 +59,10 @@ func handleListCompetitions(w http.ResponseWriter, r *http.Request) {
|
|||||||
var c Competition
|
var c Competition
|
||||||
var allow int
|
var allow int
|
||||||
if u.IsSystemAdmin {
|
if u.IsSystemAdmin {
|
||||||
rows.Scan(&c.ID, &c.Name, &allow, &c.CreatedAt)
|
rows.Scan(&c.ID, &c.Name, &allow, &c.RulesLanguage, &c.CreatedAt)
|
||||||
c.Role = "system_admin"
|
c.Role = "system_admin"
|
||||||
} else {
|
} else {
|
||||||
rows.Scan(&c.ID, &c.Name, &allow, &c.CreatedAt, &c.Role)
|
rows.Scan(&c.ID, &c.Name, &allow, &c.RulesLanguage, &c.CreatedAt, &c.Role)
|
||||||
}
|
}
|
||||||
c.AllowAnyScorerEdit = allow == 1
|
c.AllowAnyScorerEdit = allow == 1
|
||||||
out = append(out, c)
|
out = append(out, c)
|
||||||
@@ -74,6 +74,7 @@ func handleCreateCompetition(w http.ResponseWriter, r *http.Request) {
|
|||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
AllowAnyScorerEdit bool `json:"allow_any_scorer_edit"`
|
AllowAnyScorerEdit bool `json:"allow_any_scorer_edit"`
|
||||||
|
RulesLanguage string `json:"rules_language"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid_body")
|
writeError(w, http.StatusBadRequest, "invalid_body")
|
||||||
@@ -91,16 +92,30 @@ func handleCreateCompetition(w http.ResponseWriter, r *http.Request) {
|
|||||||
if req.AllowAnyScorerEdit {
|
if req.AllowAnyScorerEdit {
|
||||||
allow = 1
|
allow = 1
|
||||||
}
|
}
|
||||||
res, err := db.Exec("INSERT INTO competitions(name,allow_any_scorer_edit) VALUES(?,?)", req.Name, allow)
|
lang := normalizeRulesLanguage(req.RulesLanguage)
|
||||||
|
res, err := db.Exec("INSERT INTO competitions(name,allow_any_scorer_edit,rules_language) VALUES(?,?,?)", req.Name, allow, lang)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "db_error")
|
writeError(w, http.StatusInternalServerError, "db_error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id, _ := res.LastInsertId()
|
id, _ := res.LastInsertId()
|
||||||
c := Competition{ID: id, Name: req.Name, AllowAnyScorerEdit: req.AllowAnyScorerEdit}
|
c := Competition{ID: id, Name: req.Name, AllowAnyScorerEdit: req.AllowAnyScorerEdit, RulesLanguage: lang}
|
||||||
writeJSON(w, http.StatusCreated, c)
|
writeJSON(w, http.StatusCreated, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeRulesLanguage(lang string) string {
|
||||||
|
if lang == "" {
|
||||||
|
return "en"
|
||||||
|
}
|
||||||
|
rulesMu.RLock()
|
||||||
|
_, ok := rules[lang]
|
||||||
|
rulesMu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return "en"
|
||||||
|
}
|
||||||
|
return lang
|
||||||
|
}
|
||||||
|
|
||||||
func handleGetCompetition(w http.ResponseWriter, r *http.Request) {
|
func handleGetCompetition(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -115,8 +130,8 @@ func handleGetCompetition(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
var c Competition
|
var c Competition
|
||||||
var allow int
|
var allow int
|
||||||
err = db.QueryRow("SELECT id,name,allow_any_scorer_edit,created_at FROM competitions WHERE id=?", id).
|
err = db.QueryRow("SELECT id,name,allow_any_scorer_edit,rules_language,created_at FROM competitions WHERE id=?", id).
|
||||||
Scan(&c.ID, &c.Name, &allow, &c.CreatedAt)
|
Scan(&c.ID, &c.Name, &allow, &c.RulesLanguage, &c.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusNotFound, "not_found")
|
writeError(w, http.StatusNotFound, "not_found")
|
||||||
return
|
return
|
||||||
@@ -141,6 +156,7 @@ func handleUpdateCompetition(w http.ResponseWriter, r *http.Request) {
|
|||||||
var req struct {
|
var req struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
AllowAnyScorerEdit *bool `json:"allow_any_scorer_edit"`
|
AllowAnyScorerEdit *bool `json:"allow_any_scorer_edit"`
|
||||||
|
RulesLanguage *string `json:"rules_language"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid_body")
|
writeError(w, http.StatusBadRequest, "invalid_body")
|
||||||
@@ -156,6 +172,9 @@ func handleUpdateCompetition(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
db.Exec("UPDATE competitions SET allow_any_scorer_edit=? WHERE id=?", v, id)
|
db.Exec("UPDATE competitions SET allow_any_scorer_edit=? WHERE id=?", v, id)
|
||||||
}
|
}
|
||||||
|
if req.RulesLanguage != nil {
|
||||||
|
db.Exec("UPDATE competitions SET rules_language=? WHERE id=?", normalizeRulesLanguage(*req.RulesLanguage), id)
|
||||||
|
}
|
||||||
hub.broadcast(id, "competition_updated", nil)
|
hub.broadcast(id, "competition_updated", nil)
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ func migrate() error {
|
|||||||
// Idempotent column additions for older databases.
|
// Idempotent column additions for older databases.
|
||||||
addColumns := []string{
|
addColumns := []string{
|
||||||
`ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE competitions ADD COLUMN rules_language TEXT NOT NULL DEFAULT 'en'`,
|
||||||
}
|
}
|
||||||
for _, s := range addColumns {
|
for _, s := range addColumns {
|
||||||
// Ignore "duplicate column" errors so the migration is idempotent.
|
// Ignore "duplicate column" errors so the migration is idempotent.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type Competition struct {
|
|||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
AllowAnyScorerEdit bool `json:"allow_any_scorer_edit"`
|
AllowAnyScorerEdit bool `json:"allow_any_scorer_edit"`
|
||||||
|
RulesLanguage string `json:"rules_language"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
Role string `json:"role,omitempty"`
|
Role string `json:"role,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,27 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// sanitizePilotField trims whitespace, strips a leading CSV-formula prefix
|
||||||
|
// (=, +, -, @, tab, CR) so the value can never be interpreted as a formula
|
||||||
|
// when re-exported in a spreadsheet, and clips to the given byte length.
|
||||||
|
func sanitizePilotField(s string, max int) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
for len(s) > 0 {
|
||||||
|
switch s[0] {
|
||||||
|
case '=', '+', '-', '@', '\t', '\r':
|
||||||
|
s = strings.TrimLeftFunc(s[1:], func(r rune) bool {
|
||||||
|
return r == '=' || r == '+' || r == '-' || r == '@' || r == '\t' || r == '\r' || r == ' '
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if len(s) > max {
|
||||||
|
s = s[:max]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
func registerPilotRoutes(mux *http.ServeMux) {
|
func registerPilotRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /api/competitions/{id}/pilots", requireAuth(handleListPilots))
|
mux.HandleFunc("GET /api/competitions/{id}/pilots", requireAuth(handleListPilots))
|
||||||
mux.HandleFunc("POST /api/competitions/{id}/pilots", requireAuth(handleCreatePilot))
|
mux.HandleFunc("POST /api/competitions/{id}/pilots", requireAuth(handleCreatePilot))
|
||||||
@@ -153,7 +174,8 @@ func handleImportPilots(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusForbidden, "forbidden")
|
writeError(w, http.StatusForbidden, "forbidden")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
body, err := io.ReadAll(r.Body)
|
// Cap the upload to keep memory bounded.
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, 2*1024*1024))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "read_error")
|
writeError(w, http.StatusBadRequest, "read_error")
|
||||||
return
|
return
|
||||||
@@ -188,16 +210,16 @@ func handleImportPilots(w http.ResponseWriter, r *http.Request) {
|
|||||||
if len(rec) < 3 {
|
if len(rec) < 3 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
number := strings.TrimSpace(rec[0])
|
number := sanitizePilotField(rec[0], 32)
|
||||||
lastName := strings.TrimSpace(rec[1])
|
lastName := sanitizePilotField(rec[1], 128)
|
||||||
firstName := strings.TrimSpace(rec[2])
|
firstName := sanitizePilotField(rec[2], 128)
|
||||||
country := ""
|
country := ""
|
||||||
balloon := ""
|
balloon := ""
|
||||||
if len(rec) >= 4 {
|
if len(rec) >= 4 {
|
||||||
country = strings.TrimSpace(rec[3])
|
country = sanitizePilotField(rec[3], 64)
|
||||||
}
|
}
|
||||||
if len(rec) >= 5 {
|
if len(rec) >= 5 {
|
||||||
balloon = strings.TrimSpace(rec[4])
|
balloon = sanitizePilotField(rec[4], 64)
|
||||||
}
|
}
|
||||||
if number == "" {
|
if number == "" {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -90,9 +90,20 @@ func getRules(lang string) []Rule {
|
|||||||
|
|
||||||
func registerRuleRoutes(mux *http.ServeMux) {
|
func registerRuleRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("GET /api/rules", requireAuth(handleListRules))
|
mux.HandleFunc("GET /api/rules", requireAuth(handleListRules))
|
||||||
|
mux.HandleFunc("GET /api/rules/languages", requireAuth(handleListRuleLanguages))
|
||||||
mux.HandleFunc("POST /api/rules/reload", requireAdmin(handleReloadRules))
|
mux.HandleFunc("POST /api/rules/reload", requireAdmin(handleReloadRules))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleListRuleLanguages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rulesMu.RLock()
|
||||||
|
defer rulesMu.RUnlock()
|
||||||
|
out := make([]string, 0, len(rules))
|
||||||
|
for lang := range rules {
|
||||||
|
out = append(out, lang)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
func handleListRules(w http.ResponseWriter, r *http.Request) {
|
func handleListRules(w http.ResponseWriter, r *http.Request) {
|
||||||
lang := r.URL.Query().Get("lang")
|
lang := r.URL.Query().Get("lang")
|
||||||
if lang == "" {
|
if lang == "" {
|
||||||
|
|||||||
@@ -52,11 +52,14 @@ func handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, "missing_fields")
|
writeError(w, http.StatusBadRequest, "missing_fields")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(req.Username) > maxUsernameLen || len(req.Password) > maxPasswordLen ||
|
if len(req.Username) > maxUsernameLen || len(req.DisplayName) > maxDisplayNameLen {
|
||||||
len(req.DisplayName) > maxDisplayNameLen {
|
|
||||||
writeError(w, http.StatusBadRequest, "too_long")
|
writeError(w, http.StatusBadRequest, "too_long")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if msg := validatePassword(req.Password); msg != "" {
|
||||||
|
writeError(w, http.StatusBadRequest, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
if req.IsSystemAdmin && !actor.IsSystemAdmin {
|
if req.IsSystemAdmin && !actor.IsSystemAdmin {
|
||||||
writeError(w, http.StatusForbidden, "forbidden")
|
writeError(w, http.StatusForbidden, "forbidden")
|
||||||
return
|
return
|
||||||
@@ -148,8 +151,8 @@ func handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
db.Exec("UPDATE users SET display_name=? WHERE id=?", *req.DisplayName, id)
|
db.Exec("UPDATE users SET display_name=? WHERE id=?", *req.DisplayName, id)
|
||||||
}
|
}
|
||||||
if req.Password != nil && *req.Password != "" {
|
if req.Password != nil && *req.Password != "" {
|
||||||
if len(*req.Password) > maxPasswordLen {
|
if msg := validatePassword(*req.Password); msg != "" {
|
||||||
writeError(w, http.StatusBadRequest, "too_long")
|
writeError(w, http.StatusBadRequest, msg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hash, _ := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
|
hash, _ := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ const API = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
listRules: (lang) => api("GET", `/api/rules${lang ? "?lang=" + encodeURIComponent(lang) : ""}`),
|
listRules: (lang) => api("GET", `/api/rules${lang ? "?lang=" + encodeURIComponent(lang) : ""}`),
|
||||||
|
listRuleLanguages: () => api("GET", "/api/rules/languages"),
|
||||||
};
|
};
|
||||||
|
|
||||||
function openCompetitionWS(id, handlers) {
|
function openCompetitionWS(id, handlers) {
|
||||||
|
|||||||
+118
-28
@@ -23,6 +23,7 @@
|
|||||||
users: [],
|
users: [],
|
||||||
rules: [],
|
rules: [],
|
||||||
rulesByNumber: {},
|
rulesByNumber: {},
|
||||||
|
ruleLanguages: [],
|
||||||
ws: null,
|
ws: null,
|
||||||
wsOnline: false,
|
wsOnline: false,
|
||||||
tab: params.tab || "penalties",
|
tab: params.tab || "penalties",
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
await Promise.all([loadPilots(), loadPenalties(), loadMembers(), loadRules()]);
|
await Promise.all([loadPilots(), loadPenalties(), loadMembers(), loadRules(), loadRuleLanguages()]);
|
||||||
if (user.is_system_admin) await loadUsers();
|
if (user.is_system_admin) await loadUsers();
|
||||||
}
|
}
|
||||||
async function loadPilots() { state.pilots = await API.listPilots(competitionId); }
|
async function loadPilots() { state.pilots = await API.listPilots(competitionId); }
|
||||||
@@ -41,10 +42,15 @@
|
|||||||
async function loadMembers() { state.members = await API.listMembers(competitionId); }
|
async function loadMembers() { state.members = await API.listMembers(competitionId); }
|
||||||
async function loadUsers() { state.users = await API.listUsers(); }
|
async function loadUsers() { state.users = await API.listUsers(); }
|
||||||
async function loadRules() {
|
async function loadRules() {
|
||||||
state.rules = await API.listRules(user.language);
|
const lang = state.competition.rules_language || user.language;
|
||||||
|
state.rules = await API.listRules(lang);
|
||||||
state.rulesByNumber = {};
|
state.rulesByNumber = {};
|
||||||
for (const r of state.rules) state.rulesByNumber[r.number] = r;
|
for (const r of state.rules) state.rulesByNumber[r.number] = r;
|
||||||
}
|
}
|
||||||
|
async function loadRuleLanguages() {
|
||||||
|
try { state.ruleLanguages = await API.listRuleLanguages(); }
|
||||||
|
catch (e) { state.ruleLanguages = []; }
|
||||||
|
}
|
||||||
|
|
||||||
function isChief() {
|
function isChief() {
|
||||||
const r = state.competition.role;
|
const r = state.competition.role;
|
||||||
@@ -531,15 +537,19 @@
|
|||||||
el("th", null, t("flight")),
|
el("th", null, t("flight")),
|
||||||
el("th", null, t("date")),
|
el("th", null, t("date")),
|
||||||
el("th", null, t("rule_number_short")),
|
el("th", null, t("rule_number_short")),
|
||||||
|
el("th", null, t("rule")),
|
||||||
el("th", null, t("penalty_values")),
|
el("th", null, t("penalty_values")),
|
||||||
el("th", null, t("description")),
|
el("th", null, t("description")),
|
||||||
)));
|
)));
|
||||||
const tb = el("tbody");
|
const tb = el("tbody");
|
||||||
for (const p of entry.penalties) {
|
for (const p of entry.penalties) {
|
||||||
|
const rule = p.rule_number ? state.rulesByNumber[p.rule_number] : null;
|
||||||
|
const ruleText = rule ? rule.text : (p.rule_number ? t("rule_not_found") : "");
|
||||||
tb.appendChild(el("tr", null,
|
tb.appendChild(el("tr", null,
|
||||||
el("td", null, p.flight || ""),
|
el("td", null, p.flight || ""),
|
||||||
el("td", null, p.date || ""),
|
el("td", null, p.date || ""),
|
||||||
el("td", null, p.rule_number || ""),
|
el("td", null, p.rule_number || ""),
|
||||||
|
el("td", { class: "wrap-cell" }, ruleText),
|
||||||
el("td", null, el("span", { class: "badge accent" }, p.penalties_text || "")),
|
el("td", null, el("span", { class: "badge accent" }, p.penalties_text || "")),
|
||||||
el("td", null, p.description || ""),
|
el("td", null, p.description || ""),
|
||||||
));
|
));
|
||||||
@@ -598,6 +608,7 @@
|
|||||||
el("th", null, t("pilot_name")),
|
el("th", null, t("pilot_name")),
|
||||||
el("th", null, t("flight")),
|
el("th", null, t("flight")),
|
||||||
el("th", null, t("rule_number_short")),
|
el("th", null, t("rule_number_short")),
|
||||||
|
el("th", null, t("rule")),
|
||||||
el("th", null, t("penalty_values")),
|
el("th", null, t("penalty_values")),
|
||||||
el("th", null, t("description")),
|
el("th", null, t("description")),
|
||||||
)));
|
)));
|
||||||
@@ -607,11 +618,14 @@
|
|||||||
naturalCompare(a.flight, b.flight) ||
|
naturalCompare(a.flight, b.flight) ||
|
||||||
(a.id - b.id));
|
(a.id - b.id));
|
||||||
for (const p of items) {
|
for (const p of items) {
|
||||||
|
const rule = p.rule_number ? state.rulesByNumber[p.rule_number] : null;
|
||||||
|
const ruleText = rule ? rule.text : (p.rule_number ? t("rule_not_found") : "");
|
||||||
tb.appendChild(el("tr", null,
|
tb.appendChild(el("tr", null,
|
||||||
el("td", null, p.pilot_number || ""),
|
el("td", null, p.pilot_number || ""),
|
||||||
el("td", null, p.pilot_name || ""),
|
el("td", null, p.pilot_name || ""),
|
||||||
el("td", null, p.flight || ""),
|
el("td", null, p.flight || ""),
|
||||||
el("td", null, p.rule_number || ""),
|
el("td", null, p.rule_number || ""),
|
||||||
|
el("td", { class: "wrap-cell" }, ruleText),
|
||||||
el("td", null, el("span", { class: "badge accent" }, p.penalties_text || "")),
|
el("td", null, el("span", { class: "badge accent" }, p.penalties_text || "")),
|
||||||
el("td", null, p.description || ""),
|
el("td", null, p.description || ""),
|
||||||
));
|
));
|
||||||
@@ -967,41 +981,99 @@
|
|||||||
return el("span", null, "");
|
return el("span", null, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RULE_COLS = [
|
||||||
|
{ key: "number", label: "rule_number_short", className: "col-num" },
|
||||||
|
{ key: "text", label: "rule", className: "col-text" },
|
||||||
|
{ key: "suggested_penalty", label: "suggested_penalty", className: "col-suggested" },
|
||||||
|
{ key: "escalation_mode", label: "escalation", className: "col-escalation" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function escalationSortKey(r) {
|
||||||
|
if (r.escalation_mode === "same") return "1";
|
||||||
|
if (r.escalation_mode === "doubled") return "2";
|
||||||
|
if (r.escalation_mode === "escalate") return "3";
|
||||||
|
return "9";
|
||||||
|
}
|
||||||
|
|
||||||
function renderRulesTab() {
|
function renderRulesTab() {
|
||||||
|
if (!state.ruleSort) state.ruleSort = { col: "number", dir: "asc" };
|
||||||
|
|
||||||
const card = el("div");
|
const card = el("div");
|
||||||
const search = el("input", { type: "search", placeholder: t("search_rule"), style: { width: "100%" } });
|
const search = el("input", { type: "search", placeholder: t("search_rule"), style: { flex: "1", minWidth: "240px" },
|
||||||
|
value: state.ruleFilter || "",
|
||||||
|
oninput: (e) => { state.ruleFilter = e.target.value; refresh(); }
|
||||||
|
});
|
||||||
const count = el("div", { class: "muted", style: { margin: "0.5rem 0" } });
|
const count = el("div", { class: "muted", style: { margin: "0.5rem 0" } });
|
||||||
const list = el("div", { class: "rules-list" });
|
|
||||||
|
const tableWrap = el("div", { class: "table-wrap" });
|
||||||
|
const table = el("table", { class: "rules-table" });
|
||||||
|
const thead = el("thead");
|
||||||
|
const headRow = el("tr");
|
||||||
|
for (const c of RULE_COLS) {
|
||||||
|
headRow.appendChild(el("th", {
|
||||||
|
class: c.className,
|
||||||
|
onclick: () => {
|
||||||
|
if (state.ruleSort.col === c.key) state.ruleSort.dir = state.ruleSort.dir === "asc" ? "desc" : "asc";
|
||||||
|
else { state.ruleSort.col = c.key; state.ruleSort.dir = "asc"; }
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
t(c.label),
|
||||||
|
el("span", { class: "sort-ind" }, state.ruleSort.col === c.key ? (state.ruleSort.dir === "asc" ? "▲" : "▼") : ""),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
thead.appendChild(headRow);
|
||||||
|
table.appendChild(thead);
|
||||||
|
const tbody = el("tbody");
|
||||||
|
table.appendChild(tbody);
|
||||||
|
tableWrap.appendChild(table);
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
list.innerHTML = "";
|
tbody.innerHTML = "";
|
||||||
const q = search.value.trim().toLowerCase();
|
const ths = headRow.querySelectorAll("th");
|
||||||
|
RULE_COLS.forEach((c, i) => {
|
||||||
|
const ind = ths[i].querySelector(".sort-ind");
|
||||||
|
if (ind) ind.textContent = state.ruleSort.col === c.key ? (state.ruleSort.dir === "asc" ? "▲" : "▼") : "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const q = (state.ruleFilter || "").trim().toLowerCase();
|
||||||
const filtered = q
|
const filtered = q
|
||||||
? state.rules.filter((r) => r.number.toLowerCase().includes(q) || r.text.toLowerCase().includes(q))
|
? state.rules.filter((r) =>
|
||||||
: state.rules;
|
r.number.toLowerCase().includes(q) ||
|
||||||
filtered.sort((a, b) => naturalCompare(a.number, b.number));
|
r.text.toLowerCase().includes(q) ||
|
||||||
|
(r.suggested_penalty || "").toLowerCase().includes(q))
|
||||||
|
: [...state.rules];
|
||||||
|
const { col, dir } = state.ruleSort;
|
||||||
|
const sign = dir === "asc" ? 1 : -1;
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const av = col === "escalation_mode" ? escalationSortKey(a) : (a[col] || "");
|
||||||
|
const bv = col === "escalation_mode" ? escalationSortKey(b) : (b[col] || "");
|
||||||
|
return naturalCompare(av, bv) * sign;
|
||||||
|
});
|
||||||
|
|
||||||
count.textContent = t("showing_n_of_m", { n: filtered.length, m: state.rules.length });
|
count.textContent = t("showing_n_of_m", { n: filtered.length, m: state.rules.length });
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
tbody.appendChild(el("tr", null, el("td", { colspan: String(RULE_COLS.length), class: "muted" }, t("none"))));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const r of filtered) {
|
for (const r of filtered) {
|
||||||
list.appendChild(el("div", { class: "rule-card" },
|
tbody.appendChild(el("tr", null,
|
||||||
el("div", { class: "rule-head" },
|
el("td", { class: "col-num" }, el("span", { class: "rule-num" }, r.number)),
|
||||||
el("span", { class: "rule-num" }, r.number),
|
el("td", { class: "col-text wrap-cell" }, r.text),
|
||||||
el("span", { class: "rule-text" }, r.text),
|
el("td", { class: "col-suggested wrap-cell" },
|
||||||
),
|
r.suggested_penalty
|
||||||
el("div", { class: "kv" },
|
? el("span", { class: "badge accent" }, r.suggested_penalty)
|
||||||
el("span", { class: "k" }, t("suggested_penalty")),
|
: el("span", { class: "muted" }, "—")),
|
||||||
el("span", { class: "badge accent" }, r.suggested_penalty),
|
el("td", { class: "col-escalation" }, ruleEscalationViz(r)),
|
||||||
),
|
|
||||||
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(el("div", { class: "toolbar" }, search));
|
||||||
card.appendChild(count);
|
card.appendChild(count);
|
||||||
card.appendChild(list);
|
card.appendChild(tableWrap);
|
||||||
setTimeout(refresh, 0);
|
setTimeout(refresh, 0);
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
@@ -1127,20 +1199,33 @@
|
|||||||
function renderSettingsTab() {
|
function renderSettingsTab() {
|
||||||
const name = el("input", { type: "text", value: state.competition.name });
|
const name = el("input", { type: "text", value: state.competition.name });
|
||||||
const allow = el("input", { type: "checkbox", checked: !!state.competition.allow_any_scorer_edit });
|
const allow = el("input", { type: "checkbox", checked: !!state.competition.allow_any_scorer_edit });
|
||||||
|
const currentRulesLang = state.competition.rules_language || "en";
|
||||||
|
const langOptions = (state.ruleLanguages && state.ruleLanguages.length)
|
||||||
|
? [...state.ruleLanguages].sort()
|
||||||
|
: I18N_AVAILABLE;
|
||||||
|
const rulesLang = el("select", null,
|
||||||
|
...langOptions.map((l) => el("option", { value: l, selected: l === currentRulesLang },
|
||||||
|
I18N_NAMES[l] || l))
|
||||||
|
);
|
||||||
const msg = el("div", { class: "muted", style: { color: "var(--accent)", display: "none" } });
|
const msg = el("div", { class: "muted", style: { color: "var(--accent)", display: "none" } });
|
||||||
return el("div", { class: "card" },
|
return el("div", { class: "card" },
|
||||||
el("h2", null, t("settings_tab")),
|
el("h2", null, t("settings_tab")),
|
||||||
el("div", { class: "field" }, el("label", null, t("competition_name")), name),
|
el("div", { class: "field" }, el("label", null, t("competition_name")), name),
|
||||||
|
el("div", { class: "field" }, el("label", null, t("rules_language")), rulesLang,
|
||||||
|
el("div", { class: "muted small" }, t("rules_language_hint"))),
|
||||||
el("label", { class: "row", style: { marginTop: "0.5rem" } }, allow, " " + t("allow_any_scorer_edit")),
|
el("label", { class: "row", style: { marginTop: "0.5rem" } }, allow, " " + t("allow_any_scorer_edit")),
|
||||||
msg,
|
msg,
|
||||||
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
|
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
|
||||||
el("button", { class: "primary", onclick: async () => {
|
el("button", { class: "primary", onclick: async () => {
|
||||||
try {
|
try {
|
||||||
await API.updateCompetition(competitionId, {
|
await API.updateCompetition(competitionId, {
|
||||||
name: name.value, allow_any_scorer_edit: allow.checked,
|
name: name.value,
|
||||||
|
allow_any_scorer_edit: allow.checked,
|
||||||
|
rules_language: rulesLang.value,
|
||||||
});
|
});
|
||||||
const c = await API.getCompetition(competitionId);
|
const c = await API.getCompetition(competitionId);
|
||||||
state.competition = c;
|
state.competition = c;
|
||||||
|
await loadRules();
|
||||||
msg.textContent = t("saved");
|
msg.textContent = t("saved");
|
||||||
msg.style.display = "block";
|
msg.style.display = "block";
|
||||||
} catch (e) { alert(e.message); }
|
} catch (e) { alert(e.message); }
|
||||||
@@ -1218,7 +1303,12 @@
|
|||||||
else render();
|
else render();
|
||||||
});
|
});
|
||||||
} else if (msg.type === "competition_updated") {
|
} else if (msg.type === "competition_updated") {
|
||||||
API.getCompetition(competitionId).then((c) => { state.competition = c; render(); });
|
API.getCompetition(competitionId).then(async (c) => {
|
||||||
|
const prevLang = state.competition.rules_language;
|
||||||
|
state.competition = c;
|
||||||
|
if (c.rules_language !== prevLang) await loadRules();
|
||||||
|
render();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-2
@@ -12,21 +12,37 @@
|
|||||||
if (user.is_system_admin) state.users = await API.listUsers();
|
if (user.is_system_admin) state.users = await API.listUsers();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCompetitionModal() {
|
async function openCompetitionModal() {
|
||||||
const backdrop = el("div", { class: "modal-backdrop",
|
const backdrop = el("div", { class: "modal-backdrop",
|
||||||
onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
|
onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
|
||||||
const nameInput = el("input", { type: "text" });
|
const nameInput = el("input", { type: "text" });
|
||||||
const allowInput = el("input", { type: "checkbox" });
|
const allowInput = el("input", { type: "checkbox" });
|
||||||
|
let langs = I18N_AVAILABLE;
|
||||||
|
try {
|
||||||
|
const fromApi = await API.listRuleLanguages();
|
||||||
|
if (fromApi && fromApi.length) langs = [...fromApi].sort();
|
||||||
|
} catch (e) {}
|
||||||
|
const defaultLang = langs.includes(user.language) ? user.language : "en";
|
||||||
|
const langInput = el("select", null,
|
||||||
|
...langs.map((l) => el("option", { value: l, selected: l === defaultLang },
|
||||||
|
I18N_NAMES[l] || l))
|
||||||
|
);
|
||||||
backdrop.appendChild(el("div", { class: "modal" },
|
backdrop.appendChild(el("div", { class: "modal" },
|
||||||
el("h3", null, t("new_competition")),
|
el("h3", null, t("new_competition")),
|
||||||
el("div", { class: "field" }, el("label", null, t("competition_name")), nameInput),
|
el("div", { class: "field" }, el("label", null, t("competition_name")), nameInput),
|
||||||
|
el("div", { class: "field" }, el("label", null, t("rules_language")), langInput,
|
||||||
|
el("div", { class: "muted small" }, t("rules_language_hint"))),
|
||||||
el("label", { class: "row" }, allowInput, " " + t("allow_any_scorer_edit")),
|
el("label", { class: "row" }, allowInput, " " + t("allow_any_scorer_edit")),
|
||||||
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
|
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
|
||||||
el("button", { onclick: () => backdrop.remove() }, t("cancel")),
|
el("button", { onclick: () => backdrop.remove() }, t("cancel")),
|
||||||
el("button", { class: "primary", onclick: async () => {
|
el("button", { class: "primary", onclick: async () => {
|
||||||
if (!nameInput.value.trim()) return;
|
if (!nameInput.value.trim()) return;
|
||||||
try {
|
try {
|
||||||
await API.createCompetition({ name: nameInput.value.trim(), allow_any_scorer_edit: allowInput.checked });
|
await API.createCompetition({
|
||||||
|
name: nameInput.value.trim(),
|
||||||
|
allow_any_scorer_edit: allowInput.checked,
|
||||||
|
rules_language: langInput.value,
|
||||||
|
});
|
||||||
backdrop.remove();
|
backdrop.remove();
|
||||||
await loadCompetitions();
|
await loadCompetitions();
|
||||||
render();
|
render();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
async function submit(e) {
|
async function submit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
err.style.display = "none";
|
err.style.display = "none";
|
||||||
if (pw1.value.length < 6) {
|
if (pw1.value.length < 8) {
|
||||||
err.textContent = t("password_too_short");
|
err.textContent = t("password_too_short");
|
||||||
err.style.display = "block";
|
err.style.display = "block";
|
||||||
return;
|
return;
|
||||||
|
|||||||
+30
-16
@@ -16,7 +16,9 @@ const I18N_DATA = {
|
|||||||
pilots: "Pilots", penalties: "Penalties", members: "Members", rules: "Rules",
|
pilots: "Pilots", penalties: "Penalties", members: "Members", rules: "Rules",
|
||||||
settings_tab: "Settings",
|
settings_tab: "Settings",
|
||||||
number: "Number", last_name: "Last name", first_name: "First name",
|
number: "Number", last_name: "Last name", first_name: "First name",
|
||||||
country: "Country", balloon_id: "Balloon ID",
|
country: "Country", balloon_id: "Registration",
|
||||||
|
rules_language: "Rules language",
|
||||||
|
rules_language_hint: "Language of rule texts loaded for this competition (UI language stays per user)",
|
||||||
add_pilot: "Add pilot", import_csv: "Import CSV", export_csv: "Export CSV",
|
add_pilot: "Add pilot", import_csv: "Import CSV", export_csv: "Export CSV",
|
||||||
flight: "Flight", date: "Date", pilot_number: "Pilot No.",
|
flight: "Flight", date: "Date", pilot_number: "Pilot No.",
|
||||||
pilot_name: "Pilot name", rule: "Rule", task: "Task",
|
pilot_name: "Pilot name", rule: "Rule", task: "Task",
|
||||||
@@ -33,7 +35,7 @@ const I18N_DATA = {
|
|||||||
display_name: "Display name", is_admin: "Admin",
|
display_name: "Display name", is_admin: "Admin",
|
||||||
allow_any_scorer_edit: "Allow any scorer to edit penalties",
|
allow_any_scorer_edit: "Allow any scorer to edit penalties",
|
||||||
open: "Open", back: "Back", change_password: "Change password",
|
open: "Open", back: "Back", change_password: "Change password",
|
||||||
new_password: "New password", csv_paste: "Paste CSV (number,last,first,country,balloon)",
|
new_password: "New password", csv_paste: "Paste CSV (number,last,first,country,registration)",
|
||||||
no_pilots: "No pilots yet", no_penalties: "No penalties yet",
|
no_pilots: "No pilots yet", no_penalties: "No penalties yet",
|
||||||
no_members: "No members yet", no_competitions: "No competitions",
|
no_members: "No members yet", no_competitions: "No competitions",
|
||||||
select_pilot: "Select pilot", rule_number_short: "Rule No.",
|
select_pilot: "Select pilot", rule_number_short: "Rule No.",
|
||||||
@@ -71,7 +73,7 @@ const I18N_DATA = {
|
|||||||
filter_applied: "Applied only",
|
filter_applied: "Applied only",
|
||||||
total: "Total", open: "Open",
|
total: "Total", open: "Open",
|
||||||
repeat_password: "Repeat password",
|
repeat_password: "Repeat password",
|
||||||
password_too_short: "Password must be at least 6 characters",
|
password_too_short: "Password must be at least 8 characters",
|
||||||
passwords_dont_match: "Passwords do not match",
|
passwords_dont_match: "Passwords do not match",
|
||||||
too_many_attempts: "Too many login attempts — please wait a few minutes",
|
too_many_attempts: "Too many login attempts — please wait a few minutes",
|
||||||
profile_username_readonly: "Username can only be changed by a system administrator",
|
profile_username_readonly: "Username can only be changed by a system administrator",
|
||||||
@@ -107,7 +109,9 @@ const I18N_DATA = {
|
|||||||
pilots: "Piloten", penalties: "Strafen", members: "Mitglieder", rules: "Regeln",
|
pilots: "Piloten", penalties: "Strafen", members: "Mitglieder", rules: "Regeln",
|
||||||
settings_tab: "Einstellungen",
|
settings_tab: "Einstellungen",
|
||||||
number: "Nummer", last_name: "Nachname", first_name: "Vorname",
|
number: "Nummer", last_name: "Nachname", first_name: "Vorname",
|
||||||
country: "Land", balloon_id: "Ballon-Kennung",
|
country: "Land", balloon_id: "Registration",
|
||||||
|
rules_language: "Sprache der Regeln",
|
||||||
|
rules_language_hint: "Sprache der Regeltexte für diesen Wettbewerb (Bedienoberfläche bleibt pro Benutzer)",
|
||||||
add_pilot: "Pilot hinzufügen", import_csv: "CSV importieren", export_csv: "CSV exportieren",
|
add_pilot: "Pilot hinzufügen", import_csv: "CSV importieren", export_csv: "CSV exportieren",
|
||||||
flight: "Fahrt", date: "Datum", pilot_number: "Pilot-Nr.",
|
flight: "Fahrt", date: "Datum", pilot_number: "Pilot-Nr.",
|
||||||
pilot_name: "Pilotenname", rule: "Regel", task: "Aufgabe",
|
pilot_name: "Pilotenname", rule: "Regel", task: "Aufgabe",
|
||||||
@@ -124,7 +128,7 @@ const I18N_DATA = {
|
|||||||
display_name: "Anzeigename", is_admin: "Admin",
|
display_name: "Anzeigename", is_admin: "Admin",
|
||||||
allow_any_scorer_edit: "Alle Scorer dürfen Strafen bearbeiten",
|
allow_any_scorer_edit: "Alle Scorer dürfen Strafen bearbeiten",
|
||||||
open: "Öffnen", back: "Zurück", change_password: "Passwort ändern",
|
open: "Öffnen", back: "Zurück", change_password: "Passwort ändern",
|
||||||
new_password: "Neues Passwort", csv_paste: "CSV einfügen (Nr,Nachname,Vorname,Land,Ballon)",
|
new_password: "Neues Passwort", csv_paste: "CSV einfügen (Nr,Nachname,Vorname,Land,Registration)",
|
||||||
no_pilots: "Noch keine Piloten", no_penalties: "Noch keine Strafen",
|
no_pilots: "Noch keine Piloten", no_penalties: "Noch keine Strafen",
|
||||||
no_members: "Noch keine Mitglieder", no_competitions: "Keine Wettbewerbe",
|
no_members: "Noch keine Mitglieder", no_competitions: "Keine Wettbewerbe",
|
||||||
select_pilot: "Pilot wählen", rule_number_short: "Regel-Nr.",
|
select_pilot: "Pilot wählen", rule_number_short: "Regel-Nr.",
|
||||||
@@ -162,7 +166,7 @@ const I18N_DATA = {
|
|||||||
filter_applied: "Nur angewendete",
|
filter_applied: "Nur angewendete",
|
||||||
total: "Gesamt", open: "Offen",
|
total: "Gesamt", open: "Offen",
|
||||||
repeat_password: "Passwort wiederholen",
|
repeat_password: "Passwort wiederholen",
|
||||||
password_too_short: "Passwort muss mindestens 6 Zeichen lang sein",
|
password_too_short: "Passwort muss mindestens 8 Zeichen lang sein",
|
||||||
passwords_dont_match: "Passwörter stimmen nicht überein",
|
passwords_dont_match: "Passwörter stimmen nicht überein",
|
||||||
too_many_attempts: "Zu viele Anmeldeversuche — bitte ein paar Minuten warten",
|
too_many_attempts: "Zu viele Anmeldeversuche — bitte ein paar Minuten warten",
|
||||||
profile_username_readonly: "Der Benutzername kann nur vom Systemadministrator geändert werden",
|
profile_username_readonly: "Der Benutzername kann nur vom Systemadministrator geändert werden",
|
||||||
@@ -198,7 +202,9 @@ const I18N_DATA = {
|
|||||||
pilots: "Piloci", penalties: "Kary", members: "Członkowie", rules: "Zasady",
|
pilots: "Piloci", penalties: "Kary", members: "Członkowie", rules: "Zasady",
|
||||||
settings_tab: "Ustawienia",
|
settings_tab: "Ustawienia",
|
||||||
number: "Numer", last_name: "Nazwisko", first_name: "Imię",
|
number: "Numer", last_name: "Nazwisko", first_name: "Imię",
|
||||||
country: "Kraj", balloon_id: "Oznaczenie balonu",
|
country: "Kraj", balloon_id: "Registration",
|
||||||
|
rules_language: "Język zasad",
|
||||||
|
rules_language_hint: "Język tekstu zasad dla tych zawodów (język interfejsu pozostaje per użytkownik)",
|
||||||
add_pilot: "Dodaj pilota", import_csv: "Import CSV", export_csv: "Eksport CSV",
|
add_pilot: "Dodaj pilota", import_csv: "Import CSV", export_csv: "Eksport CSV",
|
||||||
flight: "Lot", date: "Data", pilot_number: "Nr pilota",
|
flight: "Lot", date: "Data", pilot_number: "Nr pilota",
|
||||||
pilot_name: "Imię i nazwisko", rule: "Zasada", task: "Zadanie",
|
pilot_name: "Imię i nazwisko", rule: "Zasada", task: "Zadanie",
|
||||||
@@ -215,7 +221,7 @@ const I18N_DATA = {
|
|||||||
display_name: "Wyświetlana nazwa", is_admin: "Admin",
|
display_name: "Wyświetlana nazwa", is_admin: "Admin",
|
||||||
allow_any_scorer_edit: "Pozwól dowolnemu scorerowi edytować kary",
|
allow_any_scorer_edit: "Pozwól dowolnemu scorerowi edytować kary",
|
||||||
open: "Otwórz", back: "Wstecz", change_password: "Zmień hasło",
|
open: "Otwórz", back: "Wstecz", change_password: "Zmień hasło",
|
||||||
new_password: "Nowe hasło", csv_paste: "Wklej CSV (nr,nazwisko,imię,kraj,balon)",
|
new_password: "Nowe hasło", csv_paste: "Wklej CSV (nr,nazwisko,imię,kraj,registration)",
|
||||||
no_pilots: "Brak pilotów", no_penalties: "Brak kar",
|
no_pilots: "Brak pilotów", no_penalties: "Brak kar",
|
||||||
no_members: "Brak członków", no_competitions: "Brak zawodów",
|
no_members: "Brak członków", no_competitions: "Brak zawodów",
|
||||||
select_pilot: "Wybierz pilota", rule_number_short: "Nr zasady",
|
select_pilot: "Wybierz pilota", rule_number_short: "Nr zasady",
|
||||||
@@ -238,7 +244,9 @@ const I18N_DATA = {
|
|||||||
pilots: "Пилоты", penalties: "Штрафы", members: "Участники", rules: "Правила",
|
pilots: "Пилоты", penalties: "Штрафы", members: "Участники", rules: "Правила",
|
||||||
settings_tab: "Настройки",
|
settings_tab: "Настройки",
|
||||||
number: "Номер", last_name: "Фамилия", first_name: "Имя",
|
number: "Номер", last_name: "Фамилия", first_name: "Имя",
|
||||||
country: "Страна", balloon_id: "Регистрация шара",
|
country: "Страна", balloon_id: "Registration",
|
||||||
|
rules_language: "Язык правил",
|
||||||
|
rules_language_hint: "Язык текстов правил для этого соревнования (язык интерфейса — индивидуальный)",
|
||||||
add_pilot: "Добавить пилота", import_csv: "Импорт CSV", export_csv: "Экспорт CSV",
|
add_pilot: "Добавить пилота", import_csv: "Импорт CSV", export_csv: "Экспорт CSV",
|
||||||
flight: "Полёт", date: "Дата", pilot_number: "№ пилота",
|
flight: "Полёт", date: "Дата", pilot_number: "№ пилота",
|
||||||
pilot_name: "Имя пилота", rule: "Правило", task: "Задание",
|
pilot_name: "Имя пилота", rule: "Правило", task: "Задание",
|
||||||
@@ -255,7 +263,7 @@ const I18N_DATA = {
|
|||||||
display_name: "Отображаемое имя", is_admin: "Админ",
|
display_name: "Отображаемое имя", is_admin: "Админ",
|
||||||
allow_any_scorer_edit: "Любой Scorer может редактировать штрафы",
|
allow_any_scorer_edit: "Любой Scorer может редактировать штрафы",
|
||||||
open: "Открыть", back: "Назад", change_password: "Изменить пароль",
|
open: "Открыть", back: "Назад", change_password: "Изменить пароль",
|
||||||
new_password: "Новый пароль", csv_paste: "Вставьте CSV (№,фамилия,имя,страна,шар)",
|
new_password: "Новый пароль", csv_paste: "Вставьте CSV (№,фамилия,имя,страна,registration)",
|
||||||
no_pilots: "Нет пилотов", no_penalties: "Нет штрафов",
|
no_pilots: "Нет пилотов", no_penalties: "Нет штрафов",
|
||||||
no_members: "Нет участников", no_competitions: "Нет соревнований",
|
no_members: "Нет участников", no_competitions: "Нет соревнований",
|
||||||
select_pilot: "Выберите пилота", rule_number_short: "№ правила",
|
select_pilot: "Выберите пилота", rule_number_short: "№ правила",
|
||||||
@@ -278,7 +286,9 @@ const I18N_DATA = {
|
|||||||
pilots: "Pilotes", penalties: "Pénalités", members: "Membres", rules: "Règles",
|
pilots: "Pilotes", penalties: "Pénalités", members: "Membres", rules: "Règles",
|
||||||
settings_tab: "Paramètres",
|
settings_tab: "Paramètres",
|
||||||
number: "Numéro", last_name: "Nom", first_name: "Prénom",
|
number: "Numéro", last_name: "Nom", first_name: "Prénom",
|
||||||
country: "Pays", balloon_id: "Immat. ballon",
|
country: "Pays", balloon_id: "Registration",
|
||||||
|
rules_language: "Langue des règles",
|
||||||
|
rules_language_hint: "Langue des textes de règles pour cette compétition (la langue de l'interface reste par utilisateur)",
|
||||||
add_pilot: "Ajouter un pilote", import_csv: "Importer CSV", export_csv: "Exporter CSV",
|
add_pilot: "Ajouter un pilote", import_csv: "Importer CSV", export_csv: "Exporter CSV",
|
||||||
flight: "Vol", date: "Date", pilot_number: "N° pilote",
|
flight: "Vol", date: "Date", pilot_number: "N° pilote",
|
||||||
pilot_name: "Nom du pilote", rule: "Règle", task: "Épreuve",
|
pilot_name: "Nom du pilote", rule: "Règle", task: "Épreuve",
|
||||||
@@ -295,7 +305,7 @@ const I18N_DATA = {
|
|||||||
display_name: "Nom affiché", is_admin: "Admin",
|
display_name: "Nom affiché", is_admin: "Admin",
|
||||||
allow_any_scorer_edit: "Tous les scorers peuvent modifier les pénalités",
|
allow_any_scorer_edit: "Tous les scorers peuvent modifier les pénalités",
|
||||||
open: "Ouvrir", back: "Retour", change_password: "Changer le mot de passe",
|
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)",
|
new_password: "Nouveau mot de passe", csv_paste: "Coller CSV (n°,nom,prénom,pays,registration)",
|
||||||
no_pilots: "Aucun pilote", no_penalties: "Aucune pénalité",
|
no_pilots: "Aucun pilote", no_penalties: "Aucune pénalité",
|
||||||
no_members: "Aucun membre", no_competitions: "Aucune compétition",
|
no_members: "Aucun membre", no_competitions: "Aucune compétition",
|
||||||
select_pilot: "Choisir un pilote", rule_number_short: "N° règle",
|
select_pilot: "Choisir un pilote", rule_number_short: "N° règle",
|
||||||
@@ -318,7 +328,9 @@ const I18N_DATA = {
|
|||||||
pilots: "Pilotos", penalties: "Penalizaciones", members: "Miembros", rules: "Reglas",
|
pilots: "Pilotos", penalties: "Penalizaciones", members: "Miembros", rules: "Reglas",
|
||||||
settings_tab: "Ajustes",
|
settings_tab: "Ajustes",
|
||||||
number: "Número", last_name: "Apellido", first_name: "Nombre",
|
number: "Número", last_name: "Apellido", first_name: "Nombre",
|
||||||
country: "País", balloon_id: "Matrícula globo",
|
country: "País", balloon_id: "Registration",
|
||||||
|
rules_language: "Idioma de las reglas",
|
||||||
|
rules_language_hint: "Idioma de los textos de reglas para esta competición (el idioma de la interfaz se mantiene por usuario)",
|
||||||
add_pilot: "Añadir piloto", import_csv: "Importar CSV", export_csv: "Exportar CSV",
|
add_pilot: "Añadir piloto", import_csv: "Importar CSV", export_csv: "Exportar CSV",
|
||||||
flight: "Vuelo", date: "Fecha", pilot_number: "N.º piloto",
|
flight: "Vuelo", date: "Fecha", pilot_number: "N.º piloto",
|
||||||
pilot_name: "Nombre piloto", rule: "Regla", task: "Tarea",
|
pilot_name: "Nombre piloto", rule: "Regla", task: "Tarea",
|
||||||
@@ -335,7 +347,7 @@ const I18N_DATA = {
|
|||||||
display_name: "Nombre mostrado", is_admin: "Admin",
|
display_name: "Nombre mostrado", is_admin: "Admin",
|
||||||
allow_any_scorer_edit: "Cualquier scorer puede editar penalizaciones",
|
allow_any_scorer_edit: "Cualquier scorer puede editar penalizaciones",
|
||||||
open: "Abrir", back: "Atrás", change_password: "Cambiar contraseña",
|
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)",
|
new_password: "Nueva contraseña", csv_paste: "Pegar CSV (n.º,apellido,nombre,país,registration)",
|
||||||
no_pilots: "Sin pilotos", no_penalties: "Sin penalizaciones",
|
no_pilots: "Sin pilotos", no_penalties: "Sin penalizaciones",
|
||||||
no_members: "Sin miembros", no_competitions: "Sin competiciones",
|
no_members: "Sin miembros", no_competitions: "Sin competiciones",
|
||||||
select_pilot: "Elegir piloto", rule_number_short: "N.º regla",
|
select_pilot: "Elegir piloto", rule_number_short: "N.º regla",
|
||||||
@@ -358,7 +370,9 @@ const I18N_DATA = {
|
|||||||
pilots: "Pilotos", penalties: "Penalizações", members: "Membros", rules: "Regras",
|
pilots: "Pilotos", penalties: "Penalizações", members: "Membros", rules: "Regras",
|
||||||
settings_tab: "Definições",
|
settings_tab: "Definições",
|
||||||
number: "Número", last_name: "Apelido", first_name: "Nome",
|
number: "Número", last_name: "Apelido", first_name: "Nome",
|
||||||
country: "País", balloon_id: "Matrícula balão",
|
country: "País", balloon_id: "Registration",
|
||||||
|
rules_language: "Idioma das regras",
|
||||||
|
rules_language_hint: "Idioma dos textos das regras desta competição (o idioma da interface mantém-se por utilizador)",
|
||||||
add_pilot: "Adicionar piloto", import_csv: "Importar CSV", export_csv: "Exportar CSV",
|
add_pilot: "Adicionar piloto", import_csv: "Importar CSV", export_csv: "Exportar CSV",
|
||||||
flight: "Voo", date: "Data", pilot_number: "N.º piloto",
|
flight: "Voo", date: "Data", pilot_number: "N.º piloto",
|
||||||
pilot_name: "Nome do piloto", rule: "Regra", task: "Tarefa",
|
pilot_name: "Nome do piloto", rule: "Regra", task: "Tarefa",
|
||||||
@@ -375,7 +389,7 @@ const I18N_DATA = {
|
|||||||
display_name: "Nome mostrado", is_admin: "Admin",
|
display_name: "Nome mostrado", is_admin: "Admin",
|
||||||
allow_any_scorer_edit: "Qualquer scorer pode editar penalizações",
|
allow_any_scorer_edit: "Qualquer scorer pode editar penalizações",
|
||||||
open: "Abrir", back: "Voltar", change_password: "Alterar palavra-passe",
|
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)",
|
new_password: "Nova palavra-passe", csv_paste: "Colar CSV (n.º,apelido,nome,país,registration)",
|
||||||
no_pilots: "Sem pilotos", no_penalties: "Sem penalizações",
|
no_pilots: "Sem pilotos", no_penalties: "Sem penalizações",
|
||||||
no_members: "Sem membros", no_competitions: "Sem competições",
|
no_members: "Sem membros", no_competitions: "Sem competições",
|
||||||
select_pilot: "Escolher piloto", rule_number_short: "N.º regra",
|
select_pilot: "Escolher piloto", rule_number_short: "N.º regra",
|
||||||
|
|||||||
@@ -414,3 +414,39 @@ h4 { margin: 0.75rem 0 0.25rem 0; font-size: 0.95rem; }
|
|||||||
|
|
||||||
input[disabled],
|
input[disabled],
|
||||||
button[disabled] { opacity: 0.55; cursor: not-allowed; }
|
button[disabled] { opacity: 0.55; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* Rules table: keep long rule texts readable, but cap column widths so a
|
||||||
|
single long row can't blow up the layout. */
|
||||||
|
.rules-table { table-layout: fixed; width: 100%; }
|
||||||
|
.rules-table th, .rules-table td { white-space: normal; vertical-align: top; }
|
||||||
|
.rules-table th.col-num,
|
||||||
|
.rules-table td.col-num { width: 8rem; }
|
||||||
|
.rules-table th.col-suggested,
|
||||||
|
.rules-table td.col-suggested { width: 14rem; }
|
||||||
|
.rules-table th.col-escalation,
|
||||||
|
.rules-table td.col-escalation { width: 16rem; }
|
||||||
|
.rules-table td .rule-num {
|
||||||
|
display: inline-block;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: rgba(43, 108, 176, 0.08);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.wrap-cell {
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.rules-table th.col-num,
|
||||||
|
.rules-table td.col-num { width: 5rem; }
|
||||||
|
.rules-table th.col-suggested,
|
||||||
|
.rules-table td.col-suggested { width: 9rem; }
|
||||||
|
.rules-table th.col-escalation,
|
||||||
|
.rules-table td.col-escalation { width: 10rem; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user