Add rules language support and improve password validation across the app
This commit is contained in:
+118
-28
@@ -23,6 +23,7 @@
|
||||
users: [],
|
||||
rules: [],
|
||||
rulesByNumber: {},
|
||||
ruleLanguages: [],
|
||||
ws: null,
|
||||
wsOnline: false,
|
||||
tab: params.tab || "penalties",
|
||||
@@ -33,7 +34,7 @@
|
||||
};
|
||||
|
||||
async function loadAll() {
|
||||
await Promise.all([loadPilots(), loadPenalties(), loadMembers(), loadRules()]);
|
||||
await Promise.all([loadPilots(), loadPenalties(), loadMembers(), loadRules(), loadRuleLanguages()]);
|
||||
if (user.is_system_admin) await loadUsers();
|
||||
}
|
||||
async function loadPilots() { state.pilots = await API.listPilots(competitionId); }
|
||||
@@ -41,10 +42,15 @@
|
||||
async function loadMembers() { state.members = await API.listMembers(competitionId); }
|
||||
async function loadUsers() { state.users = await API.listUsers(); }
|
||||
async function loadRules() {
|
||||
state.rules = await API.listRules(user.language);
|
||||
const lang = state.competition.rules_language || user.language;
|
||||
state.rules = await API.listRules(lang);
|
||||
state.rulesByNumber = {};
|
||||
for (const r of state.rules) state.rulesByNumber[r.number] = r;
|
||||
}
|
||||
async function loadRuleLanguages() {
|
||||
try { state.ruleLanguages = await API.listRuleLanguages(); }
|
||||
catch (e) { state.ruleLanguages = []; }
|
||||
}
|
||||
|
||||
function isChief() {
|
||||
const r = state.competition.role;
|
||||
@@ -531,15 +537,19 @@
|
||||
el("th", null, t("flight")),
|
||||
el("th", null, t("date")),
|
||||
el("th", null, t("rule_number_short")),
|
||||
el("th", null, t("rule")),
|
||||
el("th", null, t("penalty_values")),
|
||||
el("th", null, t("description")),
|
||||
)));
|
||||
const tb = el("tbody");
|
||||
for (const p of entry.penalties) {
|
||||
const rule = p.rule_number ? state.rulesByNumber[p.rule_number] : null;
|
||||
const ruleText = rule ? rule.text : (p.rule_number ? t("rule_not_found") : "");
|
||||
tb.appendChild(el("tr", null,
|
||||
el("td", null, p.flight || ""),
|
||||
el("td", null, p.date || ""),
|
||||
el("td", null, p.rule_number || ""),
|
||||
el("td", { class: "wrap-cell" }, ruleText),
|
||||
el("td", null, el("span", { class: "badge accent" }, p.penalties_text || "")),
|
||||
el("td", null, p.description || ""),
|
||||
));
|
||||
@@ -598,6 +608,7 @@
|
||||
el("th", null, t("pilot_name")),
|
||||
el("th", null, t("flight")),
|
||||
el("th", null, t("rule_number_short")),
|
||||
el("th", null, t("rule")),
|
||||
el("th", null, t("penalty_values")),
|
||||
el("th", null, t("description")),
|
||||
)));
|
||||
@@ -607,11 +618,14 @@
|
||||
naturalCompare(a.flight, b.flight) ||
|
||||
(a.id - b.id));
|
||||
for (const p of items) {
|
||||
const rule = p.rule_number ? state.rulesByNumber[p.rule_number] : null;
|
||||
const ruleText = rule ? rule.text : (p.rule_number ? t("rule_not_found") : "");
|
||||
tb.appendChild(el("tr", null,
|
||||
el("td", null, p.pilot_number || ""),
|
||||
el("td", null, p.pilot_name || ""),
|
||||
el("td", null, p.flight || ""),
|
||||
el("td", null, p.rule_number || ""),
|
||||
el("td", { class: "wrap-cell" }, ruleText),
|
||||
el("td", null, el("span", { class: "badge accent" }, p.penalties_text || "")),
|
||||
el("td", null, p.description || ""),
|
||||
));
|
||||
@@ -967,41 +981,99 @@
|
||||
return el("span", null, "");
|
||||
}
|
||||
|
||||
const RULE_COLS = [
|
||||
{ key: "number", label: "rule_number_short", className: "col-num" },
|
||||
{ key: "text", label: "rule", className: "col-text" },
|
||||
{ key: "suggested_penalty", label: "suggested_penalty", className: "col-suggested" },
|
||||
{ key: "escalation_mode", label: "escalation", className: "col-escalation" },
|
||||
];
|
||||
|
||||
function escalationSortKey(r) {
|
||||
if (r.escalation_mode === "same") return "1";
|
||||
if (r.escalation_mode === "doubled") return "2";
|
||||
if (r.escalation_mode === "escalate") return "3";
|
||||
return "9";
|
||||
}
|
||||
|
||||
function renderRulesTab() {
|
||||
if (!state.ruleSort) state.ruleSort = { col: "number", dir: "asc" };
|
||||
|
||||
const card = el("div");
|
||||
const search = el("input", { type: "search", placeholder: t("search_rule"), style: { width: "100%" } });
|
||||
const search = el("input", { type: "search", placeholder: t("search_rule"), style: { flex: "1", minWidth: "240px" },
|
||||
value: state.ruleFilter || "",
|
||||
oninput: (e) => { state.ruleFilter = e.target.value; refresh(); }
|
||||
});
|
||||
const count = el("div", { class: "muted", style: { margin: "0.5rem 0" } });
|
||||
const list = el("div", { class: "rules-list" });
|
||||
|
||||
const tableWrap = el("div", { class: "table-wrap" });
|
||||
const table = el("table", { class: "rules-table" });
|
||||
const thead = el("thead");
|
||||
const headRow = el("tr");
|
||||
for (const c of RULE_COLS) {
|
||||
headRow.appendChild(el("th", {
|
||||
class: c.className,
|
||||
onclick: () => {
|
||||
if (state.ruleSort.col === c.key) state.ruleSort.dir = state.ruleSort.dir === "asc" ? "desc" : "asc";
|
||||
else { state.ruleSort.col = c.key; state.ruleSort.dir = "asc"; }
|
||||
refresh();
|
||||
}
|
||||
},
|
||||
t(c.label),
|
||||
el("span", { class: "sort-ind" }, state.ruleSort.col === c.key ? (state.ruleSort.dir === "asc" ? "▲" : "▼") : ""),
|
||||
));
|
||||
}
|
||||
thead.appendChild(headRow);
|
||||
table.appendChild(thead);
|
||||
const tbody = el("tbody");
|
||||
table.appendChild(tbody);
|
||||
tableWrap.appendChild(table);
|
||||
|
||||
function refresh() {
|
||||
list.innerHTML = "";
|
||||
const q = search.value.trim().toLowerCase();
|
||||
tbody.innerHTML = "";
|
||||
const ths = headRow.querySelectorAll("th");
|
||||
RULE_COLS.forEach((c, i) => {
|
||||
const ind = ths[i].querySelector(".sort-ind");
|
||||
if (ind) ind.textContent = state.ruleSort.col === c.key ? (state.ruleSort.dir === "asc" ? "▲" : "▼") : "";
|
||||
});
|
||||
|
||||
const q = (state.ruleFilter || "").trim().toLowerCase();
|
||||
const filtered = q
|
||||
? state.rules.filter((r) => r.number.toLowerCase().includes(q) || r.text.toLowerCase().includes(q))
|
||||
: state.rules;
|
||||
filtered.sort((a, b) => naturalCompare(a.number, b.number));
|
||||
? state.rules.filter((r) =>
|
||||
r.number.toLowerCase().includes(q) ||
|
||||
r.text.toLowerCase().includes(q) ||
|
||||
(r.suggested_penalty || "").toLowerCase().includes(q))
|
||||
: [...state.rules];
|
||||
const { col, dir } = state.ruleSort;
|
||||
const sign = dir === "asc" ? 1 : -1;
|
||||
filtered.sort((a, b) => {
|
||||
const av = col === "escalation_mode" ? escalationSortKey(a) : (a[col] || "");
|
||||
const bv = col === "escalation_mode" ? escalationSortKey(b) : (b[col] || "");
|
||||
return naturalCompare(av, bv) * sign;
|
||||
});
|
||||
|
||||
count.textContent = t("showing_n_of_m", { n: filtered.length, m: state.rules.length });
|
||||
|
||||
if (filtered.length === 0) {
|
||||
tbody.appendChild(el("tr", null, el("td", { colspan: String(RULE_COLS.length), class: "muted" }, t("none"))));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const r of filtered) {
|
||||
list.appendChild(el("div", { class: "rule-card" },
|
||||
el("div", { class: "rule-head" },
|
||||
el("span", { class: "rule-num" }, r.number),
|
||||
el("span", { class: "rule-text" }, r.text),
|
||||
),
|
||||
el("div", { class: "kv" },
|
||||
el("span", { class: "k" }, t("suggested_penalty")),
|
||||
el("span", { class: "badge accent" }, r.suggested_penalty),
|
||||
),
|
||||
el("div", { class: "kv" },
|
||||
el("span", { class: "k" }, t("escalation")),
|
||||
ruleEscalationViz(r),
|
||||
),
|
||||
tbody.appendChild(el("tr", null,
|
||||
el("td", { class: "col-num" }, el("span", { class: "rule-num" }, r.number)),
|
||||
el("td", { class: "col-text wrap-cell" }, r.text),
|
||||
el("td", { class: "col-suggested wrap-cell" },
|
||||
r.suggested_penalty
|
||||
? el("span", { class: "badge accent" }, r.suggested_penalty)
|
||||
: el("span", { class: "muted" }, "—")),
|
||||
el("td", { class: "col-escalation" }, ruleEscalationViz(r)),
|
||||
));
|
||||
}
|
||||
if (filtered.length === 0) list.appendChild(el("div", { class: "muted" }, t("none")));
|
||||
}
|
||||
search.addEventListener("input", refresh);
|
||||
card.appendChild(search);
|
||||
|
||||
card.appendChild(el("div", { class: "toolbar" }, search));
|
||||
card.appendChild(count);
|
||||
card.appendChild(list);
|
||||
card.appendChild(tableWrap);
|
||||
setTimeout(refresh, 0);
|
||||
return card;
|
||||
}
|
||||
@@ -1127,20 +1199,33 @@
|
||||
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 currentRulesLang = state.competition.rules_language || "en";
|
||||
const langOptions = (state.ruleLanguages && state.ruleLanguages.length)
|
||||
? [...state.ruleLanguages].sort()
|
||||
: I18N_AVAILABLE;
|
||||
const rulesLang = el("select", null,
|
||||
...langOptions.map((l) => el("option", { value: l, selected: l === currentRulesLang },
|
||||
I18N_NAMES[l] || l))
|
||||
);
|
||||
const msg = el("div", { class: "muted", style: { color: "var(--accent)", display: "none" } });
|
||||
return 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,
|
||||
el("div", { class: "muted small" }, t("rules_language_hint"))),
|
||||
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 () => {
|
||||
try {
|
||||
await API.updateCompetition(competitionId, {
|
||||
name: name.value, allow_any_scorer_edit: allow.checked,
|
||||
name: name.value,
|
||||
allow_any_scorer_edit: allow.checked,
|
||||
rules_language: rulesLang.value,
|
||||
});
|
||||
const c = await API.getCompetition(competitionId);
|
||||
state.competition = c;
|
||||
await loadRules();
|
||||
msg.textContent = t("saved");
|
||||
msg.style.display = "block";
|
||||
} catch (e) { alert(e.message); }
|
||||
@@ -1218,7 +1303,12 @@
|
||||
else render();
|
||||
});
|
||||
} else if (msg.type === "competition_updated") {
|
||||
API.getCompetition(competitionId).then((c) => { state.competition = c; render(); });
|
||||
API.getCompetition(competitionId).then(async (c) => {
|
||||
const prevLang = state.competition.rules_language;
|
||||
state.competition = c;
|
||||
if (c.rules_language !== prevLang) await loadRules();
|
||||
render();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user