Add multilingual support, competition close/reopen, and backup directory
This commit is contained in:
+171
-8
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
+6
-13
@@ -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");
|
||||
}
|
||||
})();
|
||||
|
||||
+68
-7
@@ -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") }),
|
||||
|
||||
@@ -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")),
|
||||
),
|
||||
));
|
||||
}
|
||||
container.appendChild(grid);
|
||||
|
||||
+39
-403
@@ -1,419 +1,50 @@
|
||||
// Lightweight i18n loader. Translation data lives in `i18n/<lang>.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";
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "Нет"
|
||||
}
|
||||
+3
-1
@@ -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]))
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user