From 777f11d93c5ffd96ae51806d934e4130bf11219d Mon Sep 17 00:00:00 2001 From: Jan Meinl Date: Sun, 17 May 2026 09:18:34 +0200 Subject: [PATCH] Add multilingual support, competition close/reopen, and backup directory --- competitions.go | 179 +++++++++++++++++- db.go | 2 + main.go | 10 + models.go | 2 + penalties.go | 16 ++ web/api.js | 2 + web/common.js | 19 +- web/competition.js | 75 +++++++- web/competitions.js | 14 +- web/i18n.js | 442 ++++---------------------------------------- web/i18n/de.json | 146 +++++++++++++++ web/i18n/en.json | 146 +++++++++++++++ web/i18n/es.json | 83 +++++++++ web/i18n/fr.json | 83 +++++++++ web/i18n/pl.json | 83 +++++++++ web/i18n/pt.json | 83 +++++++++ web/i18n/ru.json | 83 +++++++++ web/login.js | 4 +- 18 files changed, 1039 insertions(+), 433 deletions(-) create mode 100644 web/i18n/de.json create mode 100644 web/i18n/en.json create mode 100644 web/i18n/es.json create mode 100644 web/i18n/fr.json create mode 100644 web/i18n/pl.json create mode 100644 web/i18n/pt.json create mode 100644 web/i18n/ru.json diff --git a/competitions.go b/competitions.go index b945a58..dca4c1e 100644 --- a/competitions.go +++ b/competitions.go @@ -2,9 +2,15 @@ package main import ( "database/sql" + "encoding/csv" "encoding/json" + "fmt" "net/http" + "os" + "path/filepath" "strconv" + "strings" + "time" ) func registerCompetitionRoutes(mux *http.ServeMux) { @@ -13,6 +19,8 @@ func registerCompetitionRoutes(mux *http.ServeMux) { 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("POST /api/competitions/{id}/close", requireAdmin(handleCloseCompetition)) + mux.HandleFunc("POST /api/competitions/{id}/reopen", requireAdmin(handleReopenCompetition)) 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)) @@ -43,9 +51,9 @@ func handleListCompetitions(w http.ResponseWriter, r *http.Request) { var rows *sql.Rows var err error if u.IsSystemAdmin { - rows, err = db.Query("SELECT id,name,allow_any_scorer_edit,rules_language,created_at FROM competitions ORDER BY created_at DESC") + rows, err = db.Query("SELECT id,name,allow_any_scorer_edit,rules_language,closed,closed_at,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.rules_language,c.created_at,cu.role + rows, err = db.Query(`SELECT c.id,c.name,c.allow_any_scorer_edit,c.rules_language,c.closed,c.closed_at,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) } @@ -57,14 +65,15 @@ func handleListCompetitions(w http.ResponseWriter, r *http.Request) { out := []Competition{} for rows.Next() { var c Competition - var allow int + var allow, closed int if u.IsSystemAdmin { - rows.Scan(&c.ID, &c.Name, &allow, &c.RulesLanguage, &c.CreatedAt) + rows.Scan(&c.ID, &c.Name, &allow, &c.RulesLanguage, &closed, &c.ClosedAt, &c.CreatedAt) c.Role = "system_admin" } else { - rows.Scan(&c.ID, &c.Name, &allow, &c.RulesLanguage, &c.CreatedAt, &c.Role) + rows.Scan(&c.ID, &c.Name, &allow, &c.RulesLanguage, &closed, &c.ClosedAt, &c.CreatedAt, &c.Role) } c.AllowAnyScorerEdit = allow == 1 + c.Closed = closed == 1 out = append(out, c) } writeJSON(w, http.StatusOK, out) @@ -129,14 +138,15 @@ func handleGetCompetition(w http.ResponseWriter, r *http.Request) { return } var c Competition - var allow int - 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.RulesLanguage, &c.CreatedAt) + var allow, closed int + err = db.QueryRow("SELECT id,name,allow_any_scorer_edit,rules_language,closed,closed_at,created_at FROM competitions WHERE id=?", id). + Scan(&c.ID, &c.Name, &allow, &c.RulesLanguage, &closed, &c.ClosedAt, &c.CreatedAt) if err != nil { writeError(w, http.StatusNotFound, "not_found") return } c.AllowAnyScorerEdit = allow == 1 + c.Closed = closed == 1 c.Role = role writeJSON(w, http.StatusOK, c) } @@ -153,6 +163,10 @@ func handleUpdateCompetition(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusForbidden, "forbidden") return } + if closed, _ := isCompetitionClosed(id); closed && role != "system_admin" { + writeError(w, http.StatusConflict, "competition_closed") + return + } var req struct { Name *string `json:"name"` AllowAnyScorerEdit *bool `json:"allow_any_scorer_edit"` @@ -189,6 +203,155 @@ func handleDeleteCompetition(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +func isCompetitionClosed(id int64) (bool, error) { + var closed int + err := db.QueryRow("SELECT closed FROM competitions WHERE id=?", id).Scan(&closed) + if err != nil { + return false, err + } + return closed == 1, nil +} + +func handleCloseCompetition(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 name, rulesLang string + if err := db.QueryRow("SELECT name,rules_language FROM competitions WHERE id=?", id).Scan(&name, &rulesLang); err != nil { + writeError(w, http.StatusNotFound, "not_found") + return + } + path, err := writeCompetitionBackup(id, name, rulesLang) + if err != nil { + writeError(w, http.StatusInternalServerError, "backup_error") + return + } + now := time.Now().UTC().Format(time.RFC3339) + if _, err := db.Exec("UPDATE competitions SET closed=1, closed_at=? WHERE id=?", now, id); err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + hub.broadcast(id, "competition_updated", nil) + writeJSON(w, http.StatusOK, map[string]string{"backup": filepath.Base(path)}) +} + +func handleReopenCompetition(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 _, err := db.Exec("UPDATE competitions SET closed=0, closed_at='' WHERE id=?", id); err != nil { + writeError(w, http.StatusInternalServerError, "db_error") + return + } + hub.broadcast(id, "competition_updated", nil) + w.WriteHeader(http.StatusNoContent) +} + +// sanitizeFilename returns a safe filesystem fragment derived from name. +func sanitizeFilename(s string) string { + s = strings.TrimSpace(s) + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + b.WriteRune(r) + case r == '-' || r == '_': + b.WriteRune(r) + case r == ' ': + b.WriteRune('_') + } + } + out := b.String() + if len(out) > 60 { + out = out[:60] + } + if out == "" { + out = "competition" + } + return out +} + +// writeCompetitionBackup writes a CSV snapshot of all penalties (joined with +// pilot and current rule text) into the backup directory and returns the path. +func writeCompetitionBackup(id int64, name, rulesLang string) (string, error) { + dir := backupDir + if dir == "" { + dir = "backup" + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + rulesMu.RLock() + ruleMap := rules[rulesLang] + if ruleMap == nil { + ruleMap = rules["en"] + } + rulesMu.RUnlock() + + 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,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`, id) + if err != nil { + return "", err + } + defer rows.Close() + + ts := time.Now().UTC().Format("20060102T150405Z") + fname := fmt.Sprintf("competition_%d_%s_%s.csv", id, sanitizeFilename(name), ts) + path := filepath.Join(dir, fname) + f, err := os.Create(path) + if err != nil { + return "", err + } + defer f.Close() + cw := csv.NewWriter(f) + defer cw.Flush() + + if err := cw.Write([]string{ + "id", "flight", "date", "pilot_number", "pilot_name", "rule_number", + "rule_text", "suggested_penalty", "task", "penalties", "description", + "created_by", "transferred", "created_at", "updated_at", + }); err != nil { + return "", err + } + for rows.Next() { + var idv int64 + var flight, date, pnum, pname, rnum, task, pens, desc, creator, createdAt, updatedAt string + var transferred int + if err := rows.Scan(&idv, &flight, &date, &pnum, &pname, &rnum, &task, &pens, &desc, &creator, &transferred, &createdAt, &updatedAt); err != nil { + return "", err + } + ruleText, suggested := "", "" + if ru, ok := ruleMap[rnum]; ok { + ruleText = ru.Text + suggested = ru.SuggestedPenalty + } + t := "0" + if transferred == 1 { + t = "1" + } + if err := cw.Write([]string{ + strconv.FormatInt(idv, 10), + csvSafe(flight), csvSafe(date), csvSafe(pnum), csvSafe(pname), + csvSafe(rnum), csvSafe(ruleText), csvSafe(suggested), + csvSafe(task), csvSafe(pens), csvSafe(desc), + csvSafe(creator), t, createdAt, updatedAt, + }); err != nil { + return "", err + } + } + return path, nil +} + func handleListMembers(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { diff --git a/db.go b/db.go index a44451d..7413a14 100644 --- a/db.go +++ b/db.go @@ -92,6 +92,8 @@ func migrate() error { addColumns := []string{ `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'`, + `ALTER TABLE competitions ADD COLUMN closed INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE competitions ADD COLUMN closed_at TEXT NOT NULL DEFAULT ''`, } for _, s := range addColumns { // Ignore "duplicate column" errors so the migration is idempotent. diff --git a/main.go b/main.go index e0f510a..9f43572 100644 --- a/main.go +++ b/main.go @@ -21,18 +21,21 @@ type Config struct { Addr string `json:"addr"` DBPath string `json:"db_path"` RulesDir string `json:"rules_dir"` + BackupDir string `json:"backup_dir"` CORSOrigins []string `json:"cors_origins"` CrossSiteCookies bool `json:"cross_site_cookies"` } var corsOrigins []string var crossSiteCookies bool +var backupDir string func defaultConfig() *Config { return &Config{ Addr: ":8080", DBPath: "penaltytracker.db", RulesDir: "rules", + BackupDir: "backup", CORSOrigins: []string{}, CrossSiteCookies: false, } @@ -78,6 +81,9 @@ func loadConfig(path string) (*Config, error) { if cfg.RulesDir == "" { cfg.RulesDir = "rules" } + if cfg.BackupDir == "" { + cfg.BackupDir = "backup" + } for i, o := range cfg.CORSOrigins { cfg.CORSOrigins[i] = strings.TrimSpace(o) } @@ -95,6 +101,9 @@ func ensureDirectories(cfg *Config) error { if err := ensureDir(cfg.RulesDir); err != nil { return err } + if err := ensureDir(cfg.BackupDir); err != nil { + return err + } if dbDir := filepath.Dir(cfg.DBPath); dbDir != "" { if err := ensureDir(dbDir); err != nil { return err @@ -118,6 +127,7 @@ func main() { corsOrigins = cfg.CORSOrigins crossSiteCookies = cfg.CrossSiteCookies + backupDir = cfg.BackupDir if err := openDB(cfg.DBPath); err != nil { log.Fatalf("db open: %v", err) diff --git a/models.go b/models.go index 5d949f6..ee8c063 100644 --- a/models.go +++ b/models.go @@ -14,6 +14,8 @@ type Competition struct { Name string `json:"name"` AllowAnyScorerEdit bool `json:"allow_any_scorer_edit"` RulesLanguage string `json:"rules_language"` + Closed bool `json:"closed"` + ClosedAt string `json:"closed_at,omitempty"` CreatedAt string `json:"created_at"` Role string `json:"role,omitempty"` } diff --git a/penalties.go b/penalties.go index 3a0c6a9..5bb451c 100644 --- a/penalties.go +++ b/penalties.go @@ -52,6 +52,10 @@ func handleApplyPenalties(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusForbidden, "forbidden") return } + if closed, _ := isCompetitionClosed(id); closed { + writeError(w, http.StatusConflict, "competition_closed") + return + } var req struct { Task string `json:"task"` IDs []int64 `json:"ids"` @@ -166,6 +170,10 @@ func handleCreatePenalty(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusForbidden, "forbidden") return } + if closed, _ := isCompetitionClosed(id); closed { + writeError(w, http.StatusConflict, "competition_closed") + return + } var pen Penalty if err := json.NewDecoder(r.Body).Decode(&pen); err != nil { writeError(w, http.StatusBadRequest, "invalid_body") @@ -239,6 +247,10 @@ func handleUpdatePenalty(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusForbidden, "forbidden") return } + if closed, _ := isCompetitionClosed(id); closed { + writeError(w, http.StatusConflict, "competition_closed") + return + } var req struct { Flight *string `json:"flight"` Date *string `json:"date"` @@ -330,6 +342,10 @@ func handleDeletePenalty(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusForbidden, "forbidden") return } + if closed, _ := isCompetitionClosed(id); closed { + writeError(w, http.StatusConflict, "competition_closed") + return + } db.Exec("DELETE FROM penalties WHERE id=?", pid) hub.broadcast(id, "penalty_deleted", map[string]int64{"id": pid}) w.WriteHeader(http.StatusNoContent) diff --git a/web/api.js b/web/api.js index 089d0f8..2b28159 100644 --- a/web/api.js +++ b/web/api.js @@ -37,6 +37,8 @@ const API = { getCompetition: (id) => api("GET", `/api/competitions/${id}`), updateCompetition: (id, b) => api("PATCH", `/api/competitions/${id}`, b), deleteCompetition: (id) => api("DELETE", `/api/competitions/${id}`), + closeCompetition: (id) => api("POST", `/api/competitions/${id}/close`), + reopenCompetition: (id) => api("POST", `/api/competitions/${id}/reopen`), listMembers: (id) => api("GET", `/api/competitions/${id}/members`), addMember: (id, b) => api("POST", `/api/competitions/${id}/members`, b), diff --git a/web/common.js b/web/common.js index ce52d7f..fa6bd87 100644 --- a/web/common.js +++ b/web/common.js @@ -55,6 +55,9 @@ function navigate(page, params) { // onlyIfMustChange: true — redirect AWAY if user must NOT change async function bootstrapAuth(options) { options = options || {}; + // Make sure English (fallback) and the browser-detected language are loaded + // before we attempt to render anything. + await setLang(detectInitialLang()); let user = null; try { user = await API.me(); @@ -66,7 +69,7 @@ async function bootstrapAuth(options) { return null; } if (!user) return null; - setLang(user.language || CURRENT_LANG); + await setLang(user.language || CURRENT_LANG); if (user.must_change_password && options.forbidIfMustChange) { navigate("forcePassword"); return null; @@ -86,7 +89,7 @@ function renderTopbar(user, opts) { const lang = e.target.value; try { await API.updateMe({ language: lang }); } catch (_) {} user.language = lang; - setLang(lang); + await setLang(lang); location.reload(); } }, ...I18N_AVAILABLE.map((l) => el("option", { value: l, selected: l === user.language }, I18N_NAMES[l])) @@ -150,7 +153,7 @@ function openProfileModal(user) { const u = await API.updateMe(body); if (u.language !== user.language) { user.language = u.language; - setLang(u.language); + await setLang(u.language); } ok.textContent = t("saved"); ok.style.display = "block"; @@ -174,13 +177,3 @@ function queryParams() { return out; } -// Initialize language from saved/browser preference before authenticated state -// is known. -(function initInitialLang() { - const userLang = navigator.language ? navigator.language.slice(0, 2) : "en"; - if (typeof I18N_AVAILABLE !== "undefined" && I18N_AVAILABLE.includes(userLang)) { - setLang(userLang); - } else { - setLang("en"); - } -})(); diff --git a/web/competition.js b/web/competition.js index 460ec54..284b9f7 100644 --- a/web/competition.js +++ b/web/competition.js @@ -56,8 +56,15 @@ const r = state.competition.role; return r === "system_admin" || r === "chief_scorer"; } + function isSysAdmin() { + return state.competition.role === "system_admin"; + } + function isClosed() { + return !!state.competition.closed; + } function canEditPenalty(p) { + if (isClosed()) return false; if (isChief()) return true; if (p.created_by === user.id) return true; return !!state.competition.allow_any_scorer_edit; @@ -139,11 +146,11 @@ ); const toolbar = el("div", { class: "toolbar" }, - el("button", { class: "primary", onclick: () => openPenaltyModal() }, t("add_penalty")), + el("button", { class: "primary", disabled: isClosed(), onclick: () => { if (!isClosed()) openPenaltyModal(); } }, t("add_penalty")), search, applyFilter, el("div", { class: "spacer" }), - isChief() && el("button", { onclick: openApplyByTaskModal }, t("apply_by_task")), + isChief() && !isClosed() && el("button", { onclick: openApplyByTaskModal }, t("apply_by_task")), isChief() && el("button", { onclick: async () => { try { const blob = await API.exportPenaltiesCSV(competitionId); @@ -1197,18 +1204,19 @@ // ---- Settings tab ------------------------------------------------------ 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 readonly = isClosed() && !isSysAdmin(); + const name = el("input", { type: "text", value: state.competition.name, disabled: readonly }); + const allow = el("input", { type: "checkbox", checked: !!state.competition.allow_any_scorer_edit, disabled: readonly }); const currentRulesLang = state.competition.rules_language || "en"; const langOptions = (state.ruleLanguages && state.ruleLanguages.length) ? [...state.ruleLanguages].sort() : I18N_AVAILABLE; - const rulesLang = el("select", null, + const rulesLang = el("select", { disabled: readonly }, ...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" } }); - return el("div", { class: "card" }, + const card = el("div", { class: "card" }, 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("rules_language")), rulesLang, @@ -1216,7 +1224,7 @@ 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 () => { + el("button", { class: "primary", disabled: readonly, onclick: async () => { try { await API.updateCompetition(competitionId, { name: name.value, @@ -1232,6 +1240,57 @@ } }, t("save_settings")), ), ); + + if (isSysAdmin()) { + const adminCard = el("div", { class: "card", style: { marginTop: "1rem" } }); + adminCard.appendChild(el("h2", null, t("admin_zone"))); + if (isClosed()) { + adminCard.appendChild(el("div", { class: "muted", style: { marginBottom: "0.5rem" } }, + t("competition_closed_explain"), + state.competition.closed_at ? " (" + state.competition.closed_at + ")" : "", + )); + adminCard.appendChild(el("div", { class: "row", style: { gap: "0.5rem", flexWrap: "wrap" } }, + el("button", { class: "primary", onclick: async () => { + if (!confirm(t("confirm_reopen_competition"))) return; + try { + await API.reopenCompetition(competitionId); + state.competition = await API.getCompetition(competitionId); + render(); + } catch (e) { alert((e.data && e.data.error) || e.message); } + } }, t("reopen_competition")), + el("button", { class: "danger", onclick: async () => { + if (!confirm(t("confirm_delete_competition"))) return; + try { + await API.deleteCompetition(competitionId); + navigate("competitions"); + } catch (e) { alert((e.data && e.data.error) || e.message); } + } }, t("delete_competition")), + )); + } else { + adminCard.appendChild(el("div", { class: "muted", style: { marginBottom: "0.5rem" } }, + t("close_competition_explain"))); + adminCard.appendChild(el("div", { class: "row", style: { gap: "0.5rem", flexWrap: "wrap" } }, + el("button", { class: "primary", onclick: async () => { + if (!confirm(t("confirm_close_competition"))) return; + try { + const res = await API.closeCompetition(competitionId); + state.competition = await API.getCompetition(competitionId); + if (res && res.backup) alert(t("backup_written", { file: res.backup })); + render(); + } catch (e) { alert((e.data && e.data.error) || e.message); } + } }, t("close_competition")), + el("button", { class: "danger", onclick: async () => { + if (!confirm(t("confirm_delete_competition"))) return; + try { + await API.deleteCompetition(competitionId); + navigate("competitions"); + } catch (e) { alert((e.data && e.data.error) || e.message); } + } }, t("delete_competition")), + )); + } + return el("div", null, card, adminCard); + } + return card; } // ---- Main render ------------------------------------------------------- @@ -1249,6 +1308,8 @@ state.competition.name, " ", el("span", { class: "badge accent" }, t(state.competition.role)), + isClosed() ? " " : null, + isClosed() ? el("span", { class: "badge warn" }, t("closed")) : null, ), el("div", { class: "row" }, el("span", { class: "connection-status " + (state.wsOnline ? "online" : "offline") }), diff --git a/web/competitions.js b/web/competitions.js index d82c7ab..3a5548a 100644 --- a/web/competitions.js +++ b/web/competitions.js @@ -199,8 +199,20 @@ el("h2", null, c.name), el("div", { class: "muted", style: { marginBottom: "0.5rem" } }, el("span", { class: "badge accent" }, t(c.role)), + c.closed ? " " : null, + c.closed ? el("span", { class: "badge warn" }, t("closed")) : null, + ), + el("div", { class: "row", style: { gap: "0.5rem" } }, + el("button", { class: "primary", onclick: () => navigate("competition", { id: c.id }) }, t("open")), + user.is_system_admin && el("button", { class: "danger", onclick: async () => { + if (!confirm(t("confirm_delete_competition_named", { name: c.name }))) return; + try { + await API.deleteCompetition(c.id); + await loadCompetitions(); + render(); + } catch (e) { alert((e.data && e.data.error) || e.message); } + } }, t("delete")), ), - el("button", { class: "primary", onclick: () => navigate("competition", { id: c.id }) }, t("open")), )); } container.appendChild(grid); diff --git a/web/i18n.js b/web/i18n.js index 7d16ead..17a23f9 100644 --- a/web/i18n.js +++ b/web/i18n.js @@ -1,419 +1,50 @@ +// Lightweight i18n loader. Translation data lives in `i18n/.json`, one +// file per language so translators can edit each file independently. + 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_BASE_PATH = "i18n"; -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: "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", - 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,registration)", - 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", - applied: "Applied", - apply_by_task: "Apply by task", - apply_by_task_explain: "Confirm all open penalties for a task at once. Penalties are only marked applied after confirmation.", - apply_n_open: "Apply {n} open", - confirm_apply_task: "Mark all {n} open penalties for task '{task}' as applied?", - summary: "Summary", - penalty_summary: "Penalty summary", - rule: "Rule", - rule_not_found: "rule not found", - close: "Close", - count: "#", - count_hint: "Number of prior penalties for this pilot and rule", - prior_count: "Prior count (this pilot & rule)", - search_penalties: "Search penalties…", - filter_all: "All", - filter_open: "Open only", - filter_applied: "Applied only", - total: "Total", open: "Open", - repeat_password: "Repeat password", - password_too_short: "Password must be at least 8 characters", - passwords_dont_match: "Passwords do not match", - 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_displayname_readonly: "Display name can only be changed by a system administrator", - must_change_password: "Must change password", - confirm_force_password: "Force this user to change their password on next request?", - force_password_change: "Force password change", - force_password_explain: "An administrator has required you to set a new password before you can continue.", - user_not_found: "User not found", - show_incidents: "Show incidents", - hide_incidents: "Hide incidents", - apply_select_task: "Pick a task to start applying its open penalties.", - no_open_penalties: "No open penalties to apply.", - no_task: "(no task)", - start_apply: "Start", - step_x_of_y: "Pilot {x} of {y}", - pilot: "Pilot", - next: "Next", - to_overview: "To overview", - apply_overview: "Overview", - apply_overview_explain: "{n} penalty/penalties will be marked applied on save.", - nothing_to_apply: "Nothing to apply.", - confirm_save_partial: "Save and apply {n} penalty/penalties reviewed so far?", - }, - 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: "Kennung", - 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", - 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,Kennung)", - 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", - applied: "Angewendet", - apply_by_task: "Pro Task anwenden", - apply_by_task_explain: "Bestätige alle offenen Strafen einer Aufgabe gemeinsam. Erst nach Bestätigung gelten die Strafen als angewendet.", - apply_n_open: "{n} offene anwenden", - confirm_apply_task: "Alle {n} offenen Strafen der Aufgabe '{task}' als angewendet markieren?", - summary: "Übersicht", - penalty_summary: "Strafen-Übersicht", - rule: "Regel", - rule_not_found: "Regel nicht gefunden", - close: "Schließen", - count: "#", - count_hint: "Anzahl früherer Strafen für diesen Piloten und diese Regel", - prior_count: "Frühere Anzahl (dieser Pilot & Regel)", - search_penalties: "Strafen durchsuchen…", - filter_all: "Alle", - filter_open: "Nur offene", - filter_applied: "Nur angewendete", - total: "Gesamt", open: "Offen", - repeat_password: "Passwort wiederholen", - password_too_short: "Passwort muss mindestens 8 Zeichen lang sein", - passwords_dont_match: "Passwörter stimmen nicht überein", - too_many_attempts: "Zu viele Anmeldeversuche — bitte ein paar Minuten warten", - profile_username_readonly: "Der Benutzername kann nur vom Systemadministrator geändert werden", - profile_displayname_readonly: "Der Anzeigename kann nur vom Systemadministrator geändert werden", - must_change_password: "Passwortwechsel erforderlich", - confirm_force_password: "Diesen Benutzer beim nächsten Zugriff zum Passwortwechsel zwingen?", - force_password_change: "Passwortwechsel erzwingen", - force_password_explain: "Ein Administrator hat festgelegt, dass du ein neues Passwort vergeben musst, bevor du fortfahren kannst.", - user_not_found: "Benutzer nicht gefunden", - show_incidents: "Vorfälle anzeigen", - hide_incidents: "Vorfälle ausblenden", - apply_select_task: "Aufgabe wählen, deren offene Strafen angewendet werden sollen.", - no_open_penalties: "Keine offenen Strafen zum Anwenden.", - no_task: "(ohne Aufgabe)", - start_apply: "Start", - step_x_of_y: "Pilot {x} von {y}", - pilot: "Pilot", - next: "Weiter", - to_overview: "Zur Übersicht", - apply_overview: "Übersicht", - apply_overview_explain: "Beim Speichern werden {n} Strafe(n) als angewendet markiert.", - nothing_to_apply: "Nichts anzuwenden.", - confirm_save_partial: "{n} bisher überprüfte Strafe(n) jetzt speichern und anwenden?", - }, - 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: "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", - 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,registration)", - 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: "Registration", - rules_language: "Язык правил", - rules_language_hint: "Язык текстов правил для этого соревнования (язык интерфейса — индивидуальный)", - 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 (№,фамилия,имя,страна,registration)", - 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: "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", - 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,registration)", - 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: "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", - 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,registration)", - 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: "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", - 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,registration)", - 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", - }, -}; - +const I18N_DATA = {}; +const I18N_PENDING = {}; let CURRENT_LANG = "en"; -function setLang(lang) { - if (!I18N_DATA[lang]) lang = "en"; +function i18nURL(lang) { + return `${I18N_BASE_PATH}/${lang}.json`; +} + +async function loadLang(lang) { + if (!I18N_AVAILABLE.includes(lang)) lang = "en"; + if (I18N_DATA[lang]) return I18N_DATA[lang]; + if (I18N_PENDING[lang]) return I18N_PENDING[lang]; + const p = fetch(i18nURL(lang), { cache: "no-cache" }) + .then((r) => r.ok ? r.json() : Promise.reject(new Error("i18n_load_failed"))) + .then((data) => { I18N_DATA[lang] = data; delete I18N_PENDING[lang]; return data; }) + .catch((err) => { delete I18N_PENDING[lang]; throw err; }); + I18N_PENDING[lang] = p; + return p; +} + +async function setLang(lang) { + if (!I18N_AVAILABLE.includes(lang)) lang = "en"; + // English is the fallback for missing keys, so make sure it is always loaded. + if (!I18N_DATA.en) { + try { await loadLang("en"); } catch (e) {} + } + if (lang !== "en") { + try { await loadLang(lang); } catch (e) {} + } 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; + const d = I18N_DATA[CURRENT_LANG] || I18N_DATA.en || {}; + const fallback = I18N_DATA.en || {}; + let str = d[key] || fallback[key] || key; if (vars) { for (const k in vars) { str = str.replaceAll("{" + k + "}", vars[k]); @@ -421,3 +52,8 @@ function t(key, vars) { } return str; } + +function detectInitialLang() { + const navLang = (navigator.language || "en").slice(0, 2); + return I18N_AVAILABLE.includes(navLang) ? navLang : "en"; +} diff --git a/web/i18n/de.json b/web/i18n/de.json new file mode 100644 index 0000000..f450913 --- /dev/null +++ b/web/i18n/de.json @@ -0,0 +1,146 @@ +{ + "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": "Kennung", + "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", + "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,Kennung)", + "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", + "applied": "Angewendet", + "apply_by_task": "Pro Task anwenden", + "apply_by_task_explain": "Bestätige alle offenen Strafen einer Aufgabe gemeinsam. Erst nach Bestätigung gelten die Strafen als angewendet.", + "apply_n_open": "{n} offene anwenden", + "confirm_apply_task": "Alle {n} offenen Strafen der Aufgabe '{task}' als angewendet markieren?", + "summary": "Übersicht", + "penalty_summary": "Strafen-Übersicht", + "rule_not_found": "Regel nicht gefunden", + "close": "Schließen", + "count": "#", + "count_hint": "Anzahl früherer Strafen für diesen Piloten und diese Regel", + "prior_count": "Frühere Anzahl (dieser Pilot & Regel)", + "search_penalties": "Strafen durchsuchen…", + "filter_all": "Alle", + "filter_open": "Nur offene", + "filter_applied": "Nur angewendete", + "total": "Gesamt", + "repeat_password": "Passwort wiederholen", + "password_too_short": "Passwort muss mindestens 8 Zeichen lang sein", + "passwords_dont_match": "Passwörter stimmen nicht überein", + "too_many_attempts": "Zu viele Anmeldeversuche — bitte ein paar Minuten warten", + "profile_username_readonly": "Der Benutzername kann nur vom Systemadministrator geändert werden", + "profile_displayname_readonly": "Der Anzeigename kann nur vom Systemadministrator geändert werden", + "must_change_password": "Passwortwechsel erforderlich", + "confirm_force_password": "Diesen Benutzer beim nächsten Zugriff zum Passwortwechsel zwingen?", + "force_password_change": "Passwortwechsel erzwingen", + "force_password_explain": "Ein Administrator hat festgelegt, dass du ein neues Passwort vergeben musst, bevor du fortfahren kannst.", + "user_not_found": "Benutzer nicht gefunden", + "show_incidents": "Vorfälle anzeigen", + "hide_incidents": "Vorfälle ausblenden", + "apply_select_task": "Aufgabe wählen, deren offene Strafen angewendet werden sollen.", + "no_open_penalties": "Keine offenen Strafen zum Anwenden.", + "no_task": "(ohne Aufgabe)", + "start_apply": "Start", + "step_x_of_y": "Pilot {x} von {y}", + "pilot": "Pilot", + "next": "Weiter", + "to_overview": "Zur Übersicht", + "apply_overview": "Übersicht", + "apply_overview_explain": "Beim Speichern werden {n} Strafe(n) als angewendet markiert.", + "nothing_to_apply": "Nichts anzuwenden.", + "confirm_save_partial": "{n} bisher überprüfte Strafe(n) jetzt speichern und anwenden?", + "closed": "Beendet", + "admin_zone": "Verwaltung", + "close_competition": "Wettbewerb beenden", + "reopen_competition": "Wettbewerb wieder öffnen", + "delete_competition": "Wettbewerb löschen", + "close_competition_explain": "Beim Beenden werden alle Strafen gesperrt und eine CSV-Sicherung mit den aktuellen Regeltexten erstellt. Ein Systemadministrator kann den Wettbewerb später wieder öffnen.", + "competition_closed_explain": "Dieser Wettbewerb ist beendet. Strafen können erst geändert werden, wenn er wieder geöffnet wird.", + "confirm_close_competition": "Diesen Wettbewerb wirklich beenden? Strafen werden gesperrt und eine CSV-Sicherung wird geschrieben.", + "confirm_reopen_competition": "Diesen Wettbewerb wieder öffnen? Strafen werden erneut bearbeitbar.", + "confirm_delete_competition": "Diesen Wettbewerb löschen? Alle Piloten, Strafen und Mitglieder werden unwiderruflich entfernt.", + "confirm_delete_competition_named": "Wettbewerb '{name}' löschen? Alle Piloten, Strafen und Mitglieder werden unwiderruflich entfernt.", + "backup_written": "Sicherung erstellt: {file}", + "competition_closed": "Wettbewerb ist beendet" +} diff --git a/web/i18n/en.json b/web/i18n/en.json new file mode 100644 index 0000000..3616a60 --- /dev/null +++ b/web/i18n/en.json @@ -0,0 +1,146 @@ +{ + "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": "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", + "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,registration)", + "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", + "applied": "Applied", + "apply_by_task": "Apply by task", + "apply_by_task_explain": "Confirm all open penalties for a task at once. Penalties are only marked applied after confirmation.", + "apply_n_open": "Apply {n} open", + "confirm_apply_task": "Mark all {n} open penalties for task '{task}' as applied?", + "summary": "Summary", + "penalty_summary": "Penalty summary", + "rule_not_found": "rule not found", + "close": "Close", + "count": "#", + "count_hint": "Number of prior penalties for this pilot and rule", + "prior_count": "Prior count (this pilot & rule)", + "search_penalties": "Search penalties…", + "filter_all": "All", + "filter_open": "Open only", + "filter_applied": "Applied only", + "total": "Total", + "repeat_password": "Repeat password", + "password_too_short": "Password must be at least 8 characters", + "passwords_dont_match": "Passwords do not match", + "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_displayname_readonly": "Display name can only be changed by a system administrator", + "must_change_password": "Must change password", + "confirm_force_password": "Force this user to change their password on next request?", + "force_password_change": "Force password change", + "force_password_explain": "An administrator has required you to set a new password before you can continue.", + "user_not_found": "User not found", + "show_incidents": "Show incidents", + "hide_incidents": "Hide incidents", + "apply_select_task": "Pick a task to start applying its open penalties.", + "no_open_penalties": "No open penalties to apply.", + "no_task": "(no task)", + "start_apply": "Start", + "step_x_of_y": "Pilot {x} of {y}", + "pilot": "Pilot", + "next": "Next", + "to_overview": "To overview", + "apply_overview": "Overview", + "apply_overview_explain": "{n} penalty/penalties will be marked applied on save.", + "nothing_to_apply": "Nothing to apply.", + "confirm_save_partial": "Save and apply {n} penalty/penalties reviewed so far?", + "closed": "Closed", + "admin_zone": "Administration", + "close_competition": "Close competition", + "reopen_competition": "Reopen competition", + "delete_competition": "Delete competition", + "close_competition_explain": "Closing a competition locks all penalties and writes a CSV backup with the current rule texts. A system administrator can reopen it later.", + "competition_closed_explain": "This competition is closed. Penalties cannot be changed until it is reopened.", + "confirm_close_competition": "Close this competition? Penalties will be locked and a CSV backup will be written.", + "confirm_reopen_competition": "Reopen this competition? Penalties will become editable again.", + "confirm_delete_competition": "Delete this competition? All pilots, penalties and members will be permanently removed.", + "confirm_delete_competition_named": "Delete competition '{name}'? All pilots, penalties and members will be permanently removed.", + "backup_written": "Backup written: {file}", + "competition_closed": "Competition is closed" +} diff --git a/web/i18n/es.json b/web/i18n/es.json new file mode 100644 index 0000000..a69b699 --- /dev/null +++ b/web/i18n/es.json @@ -0,0 +1,83 @@ +{ + "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": "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", + "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,registration)", + "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" +} diff --git a/web/i18n/fr.json b/web/i18n/fr.json new file mode 100644 index 0000000..517039d --- /dev/null +++ b/web/i18n/fr.json @@ -0,0 +1,83 @@ +{ + "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": "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", + "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,registration)", + "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" +} diff --git a/web/i18n/pl.json b/web/i18n/pl.json new file mode 100644 index 0000000..9c7431f --- /dev/null +++ b/web/i18n/pl.json @@ -0,0 +1,83 @@ +{ + "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": "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", + "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,registration)", + "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" +} diff --git a/web/i18n/pt.json b/web/i18n/pt.json new file mode 100644 index 0000000..ad55623 --- /dev/null +++ b/web/i18n/pt.json @@ -0,0 +1,83 @@ +{ + "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": "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", + "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,registration)", + "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" +} diff --git a/web/i18n/ru.json b/web/i18n/ru.json new file mode 100644 index 0000000..47adfdb --- /dev/null +++ b/web/i18n/ru.json @@ -0,0 +1,83 @@ +{ + "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": "Registration", + "rules_language": "Язык правил", + "rules_language_hint": "Язык текстов правил для этого соревнования (язык интерфейса — индивидуальный)", + "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 (№,фамилия,имя,страна,registration)", + "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": "Нет" +} diff --git a/web/login.js b/web/login.js index ae56b4a..255e49f 100644 --- a/web/login.js +++ b/web/login.js @@ -1,6 +1,8 @@ (async function () { const root = document.getElementById("app"); + await setLang(detectInitialLang()); + // If already signed in, skip the login form. try { const u = await API.me(); @@ -17,7 +19,7 @@ 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); render(); } }, + { onchange: async (e) => { await setLang(e.target.value); render(); } }, ...I18N_AVAILABLE.map((l) => el("option", { value: l, selected: l === CURRENT_LANG }, I18N_NAMES[l])) );