1164 lines
43 KiB
JavaScript
1164 lines
43 KiB
JavaScript
const el = (tag, attrs, ...children) => {
|
|
const node = document.createElement(tag);
|
|
if (attrs) {
|
|
for (const k in attrs) {
|
|
if (k === "class") node.className = attrs[k];
|
|
else if (k === "style") Object.assign(node.style, attrs[k]);
|
|
else if (k.startsWith("on") && typeof attrs[k] === "function") node.addEventListener(k.slice(2), attrs[k]);
|
|
else if (k === "checked" || k === "disabled" || k === "selected") {
|
|
if (attrs[k]) node.setAttribute(k, "");
|
|
} else if (attrs[k] !== false && attrs[k] !== null && attrs[k] !== undefined) {
|
|
node.setAttribute(k, attrs[k]);
|
|
}
|
|
}
|
|
}
|
|
for (const c of children.flat()) {
|
|
if (c === null || c === undefined || c === false) continue;
|
|
node.appendChild(typeof c === "string" || typeof c === "number" ? document.createTextNode(String(c)) : c);
|
|
}
|
|
return node;
|
|
};
|
|
|
|
const root = document.getElementById("app");
|
|
const state = {
|
|
user: null,
|
|
competitions: [],
|
|
competition: null,
|
|
pilots: [],
|
|
penalties: [],
|
|
members: [],
|
|
users: [],
|
|
rules: [],
|
|
rulesByNumber: {},
|
|
ws: null,
|
|
tab: "penalties",
|
|
sort: { col: "id", dir: "desc" },
|
|
pilotSort: { col: "number", dir: "asc" },
|
|
filterUntransferred: false,
|
|
editingPenalty: null,
|
|
showPenaltyModal: false,
|
|
showPilotModal: false,
|
|
showMemberModal: false,
|
|
showCompetitionModal: false,
|
|
showUserModal: false,
|
|
};
|
|
|
|
function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); }
|
|
|
|
function render() {
|
|
if (!state.user) {
|
|
renderLogin();
|
|
return;
|
|
}
|
|
if (state.competition) {
|
|
renderCompetition();
|
|
} else {
|
|
renderHome();
|
|
}
|
|
}
|
|
|
|
function renderLogin() {
|
|
clear(root);
|
|
const usernameInput = el("input", { type: "text", autocomplete: "username", placeholder: t("username") });
|
|
const passwordInput = el("input", { type: "password", autocomplete: "current-password", placeholder: t("password") });
|
|
const errorBox = el("div", { class: "muted", style: { color: "var(--danger)", display: "none" } });
|
|
const langSelect = el("select",
|
|
{ onchange: (e) => { setLang(e.target.value); renderLogin(); } },
|
|
...I18N_AVAILABLE.map((l) => el("option", { value: l, selected: l === CURRENT_LANG }, I18N_NAMES[l]))
|
|
);
|
|
|
|
async function doLogin(e) {
|
|
e.preventDefault();
|
|
errorBox.style.display = "none";
|
|
try {
|
|
const u = await API.login(usernameInput.value, passwordInput.value);
|
|
state.user = u;
|
|
setLang(u.language);
|
|
await loadCompetitions();
|
|
render();
|
|
} catch (err) {
|
|
errorBox.textContent = err.status === 401 ? t("invalid_credentials") : (err.message || t("invalid_credentials"));
|
|
errorBox.style.display = "block";
|
|
}
|
|
}
|
|
|
|
const form = el("form", { onsubmit: doLogin, class: "col" },
|
|
el("h1", null, t("login_title")),
|
|
el("div", { class: "field" }, el("label", null, t("username")), usernameInput),
|
|
el("div", { class: "field" }, el("label", null, t("password")), passwordInput),
|
|
errorBox,
|
|
el("button", { type: "submit", class: "primary" }, t("sign_in")),
|
|
el("div", { class: "field", style: { marginTop: "0.5rem" } },
|
|
el("label", null, t("language")), langSelect
|
|
),
|
|
);
|
|
|
|
root.appendChild(el("div", { class: "login-wrap" }, el("div", { class: "login-box" }, form)));
|
|
usernameInput.focus();
|
|
}
|
|
|
|
function renderTopbar(extra) {
|
|
const langSelect = el("select",
|
|
{ onchange: async (e) => {
|
|
const lang = e.target.value;
|
|
await API.updateMe({ language: lang });
|
|
state.user.language = lang;
|
|
setLang(lang);
|
|
await loadRules();
|
|
render();
|
|
} },
|
|
...I18N_AVAILABLE.map((l) => el("option", { value: l, selected: l === state.user.language }, I18N_NAMES[l]))
|
|
);
|
|
const logoutBtn = el("button", { class: "ghost", onclick: async () => {
|
|
await API.logout();
|
|
if (state.ws) { state.ws.close(); state.ws = null; }
|
|
state.user = null;
|
|
state.competition = null;
|
|
render();
|
|
} }, t("logout"));
|
|
|
|
const brand = el("a", { href: "#", onclick: (e) => {
|
|
e.preventDefault();
|
|
if (state.ws) { state.ws.close(); state.ws = null; }
|
|
state.competition = null;
|
|
render();
|
|
} , class: "brand" }, "Penalty Tracker");
|
|
|
|
const profileBtn = el("button", { class: "ghost", onclick: () => openProfileModal() },
|
|
state.user.display_name || state.user.username);
|
|
|
|
return el("div", { class: "topbar" },
|
|
brand,
|
|
el("div", { class: "nav" },
|
|
extra || null,
|
|
profileBtn,
|
|
langSelect,
|
|
logoutBtn,
|
|
)
|
|
);
|
|
}
|
|
|
|
function openProfileModal() {
|
|
state.showProfileModal = true;
|
|
render();
|
|
}
|
|
|
|
function renderProfileModal() {
|
|
const username = el("input", { type: "text", value: state.user.username });
|
|
const displayName = el("input", { type: "text", value: state.user.display_name || "" });
|
|
const password = el("input", { type: "password", placeholder: t("leave_blank_keep") });
|
|
const err = el("div", { class: "muted", style: { color: "var(--danger)", display: "none" } });
|
|
const ok = el("div", { class: "muted", style: { color: "var(--accent)", display: "none" } });
|
|
return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) closeAll(); } },
|
|
el("div", { class: "modal" },
|
|
el("h3", null, t("profile")),
|
|
el("div", { class: "field" }, el("label", null, t("username")), username),
|
|
el("div", { class: "field" }, el("label", null, t("display_name")), displayName),
|
|
el("div", { class: "field" }, el("label", null, t("new_password")), password),
|
|
err, ok,
|
|
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
|
|
el("button", { onclick: closeAll }, t("cancel")),
|
|
el("button", { class: "primary", onclick: async () => {
|
|
err.style.display = "none"; ok.style.display = "none";
|
|
const body = {};
|
|
const newUsername = username.value.trim();
|
|
if (newUsername && newUsername !== state.user.username) body.username = newUsername;
|
|
if (displayName.value !== (state.user.display_name || "")) body.display_name = displayName.value;
|
|
if (password.value) body.password = password.value;
|
|
try {
|
|
const u = await API.updateMe(body);
|
|
state.user = u;
|
|
ok.textContent = t("saved");
|
|
ok.style.display = "block";
|
|
setTimeout(() => { closeAll(); }, 700);
|
|
} catch (e) {
|
|
err.textContent = e.data && e.data.error === "username_taken" ? t("username_taken") : (e.message || "error");
|
|
err.style.display = "block";
|
|
}
|
|
} }, t("save")),
|
|
),
|
|
)
|
|
);
|
|
}
|
|
|
|
async function loadCompetitions() {
|
|
state.competitions = await API.listCompetitions();
|
|
}
|
|
|
|
async function loadRules() {
|
|
state.rules = await API.listRules(state.user.language);
|
|
state.rulesByNumber = {};
|
|
for (const r of state.rules) state.rulesByNumber[r.number] = r;
|
|
}
|
|
|
|
function renderHome() {
|
|
clear(root);
|
|
root.appendChild(renderTopbar());
|
|
const container = el("div", { class: "container" });
|
|
|
|
const headerRow = el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.75rem" } },
|
|
el("h2", { style: { margin: 0 } }, t("competitions")),
|
|
state.user.is_system_admin && el("button", { class: "primary", onclick: () => openCompetitionModal() }, t("new_competition"))
|
|
);
|
|
container.appendChild(headerRow);
|
|
|
|
if (state.competitions.length === 0) {
|
|
container.appendChild(el("div", { class: "muted" }, t("no_competitions")));
|
|
} else {
|
|
const list = el("div", { class: "grid" });
|
|
for (const c of state.competitions) {
|
|
list.appendChild(el("div", { class: "card" },
|
|
el("h2", null, c.name),
|
|
el("div", { class: "muted", style: { marginBottom: "0.5rem" } },
|
|
el("span", { class: "badge accent" }, t(c.role)),
|
|
),
|
|
el("button", { class: "primary", onclick: () => openCompetition(c.id) }, t("open")),
|
|
));
|
|
}
|
|
container.appendChild(list);
|
|
}
|
|
|
|
if (state.user.is_system_admin) {
|
|
container.appendChild(renderUsersAdmin());
|
|
}
|
|
|
|
root.appendChild(container);
|
|
|
|
if (state.showCompetitionModal) container.appendChild(renderCompetitionModal());
|
|
if (state.showUserModal) container.appendChild(renderUserModal());
|
|
if (state.showProfileModal) container.appendChild(renderProfileModal());
|
|
}
|
|
|
|
function renderUsersAdmin() {
|
|
const card = el("div", { class: "card" });
|
|
card.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.5rem" } },
|
|
el("h2", { style: { margin: 0 } }, t("users")),
|
|
el("button", { onclick: () => openUserModal() }, t("add_user")),
|
|
));
|
|
if (state.users.length === 0) {
|
|
card.appendChild(el("div", { class: "muted" }, "—"));
|
|
return card;
|
|
}
|
|
const table = el("table");
|
|
table.appendChild(el("thead", null,
|
|
el("tr", null,
|
|
el("th", null, t("username")),
|
|
el("th", null, t("display_name")),
|
|
el("th", null, t("language")),
|
|
el("th", null, t("is_admin")),
|
|
el("th", null, t("actions")),
|
|
)
|
|
));
|
|
const tbody = el("tbody");
|
|
for (const u of state.users) {
|
|
tbody.appendChild(el("tr", null,
|
|
el("td", null, u.username),
|
|
el("td", null, u.display_name),
|
|
el("td", null, I18N_NAMES[u.language] || u.language),
|
|
el("td", null, u.is_system_admin ? t("yes") : t("no")),
|
|
el("td", null,
|
|
u.id !== state.user.id && el("button", { class: "action-btn danger", onclick: async () => {
|
|
if (!confirm(t("confirm_delete"))) return;
|
|
await API.deleteUser(u.id);
|
|
await loadUsers();
|
|
render();
|
|
} }, t("delete")),
|
|
),
|
|
));
|
|
}
|
|
table.appendChild(tbody);
|
|
card.appendChild(el("div", { class: "table-wrap" }, table));
|
|
return card;
|
|
}
|
|
|
|
async function loadUsers() {
|
|
if (state.user.is_system_admin) {
|
|
state.users = await API.listUsers();
|
|
}
|
|
}
|
|
|
|
function openCompetitionModal() {
|
|
state.showCompetitionModal = true;
|
|
render();
|
|
}
|
|
|
|
function renderCompetitionModal() {
|
|
const nameInput = el("input", { type: "text" });
|
|
const allowInput = el("input", { type: "checkbox" });
|
|
const modal = el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target === modal) closeAll(); } },
|
|
el("div", { class: "modal" },
|
|
el("h3", null, t("new_competition")),
|
|
el("div", { class: "field" }, el("label", null, t("competition_name")), nameInput),
|
|
el("label", { class: "row" }, allowInput, " " + t("allow_any_scorer_edit")),
|
|
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
|
|
el("button", { onclick: closeAll }, t("cancel")),
|
|
el("button", { class: "primary", onclick: async () => {
|
|
if (!nameInput.value.trim()) return;
|
|
await API.createCompetition({ name: nameInput.value.trim(), allow_any_scorer_edit: allowInput.checked });
|
|
await loadCompetitions();
|
|
closeAll();
|
|
} }, t("create")),
|
|
),
|
|
)
|
|
);
|
|
return modal;
|
|
}
|
|
|
|
function openUserModal(forCompetition) {
|
|
state.showUserModal = true;
|
|
state.userModalForCompetition = forCompetition || null;
|
|
render();
|
|
}
|
|
|
|
function renderUserModal() {
|
|
const username = el("input", { type: "text" });
|
|
const password = el("input", { type: "password" });
|
|
const displayName = el("input", { type: "text" });
|
|
const langSelect = el("select", null,
|
|
...I18N_AVAILABLE.map((l) => el("option", { value: l }, I18N_NAMES[l]))
|
|
);
|
|
const isAdmin = el("input", { type: "checkbox" });
|
|
const roleSelect = el("select", null,
|
|
el("option", { value: "scorer" }, t("scorer")),
|
|
el("option", { value: "chief_scorer" }, t("chief_scorer")),
|
|
);
|
|
const forComp = state.userModalForCompetition;
|
|
return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) closeAll(); } },
|
|
el("div", { class: "modal" },
|
|
el("h3", null, t("add_user")),
|
|
el("div", { class: "field" }, el("label", null, t("username")), username),
|
|
el("div", { class: "field" }, el("label", null, t("password")), password),
|
|
el("div", { class: "field" }, el("label", null, t("display_name")), displayName),
|
|
el("div", { class: "field" }, el("label", null, t("language")), langSelect),
|
|
!forComp && state.user.is_system_admin && el("label", { class: "row" }, isAdmin, " " + t("is_admin")),
|
|
forComp && el("div", { class: "field" }, el("label", null, t("role")), roleSelect),
|
|
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
|
|
el("button", { onclick: closeAll }, t("cancel")),
|
|
el("button", { class: "primary", onclick: async () => {
|
|
if (!username.value.trim() || !password.value) return;
|
|
try {
|
|
const created = await API.createUser({
|
|
username: username.value.trim(),
|
|
password: password.value,
|
|
display_name: displayName.value,
|
|
language: langSelect.value,
|
|
is_system_admin: !forComp && isAdmin.checked,
|
|
});
|
|
if (forComp) {
|
|
await API.addMember(forComp, { user_id: created.id, role: roleSelect.value });
|
|
await loadMembers();
|
|
}
|
|
await loadUsers();
|
|
closeAll();
|
|
} catch (err) {
|
|
alert(err.message);
|
|
}
|
|
} }, t("create")),
|
|
),
|
|
)
|
|
);
|
|
}
|
|
|
|
function closeAll() {
|
|
state.showCompetitionModal = false;
|
|
state.showUserModal = false;
|
|
state.showPilotModal = false;
|
|
state.showPenaltyModal = false;
|
|
state.showMemberModal = false;
|
|
state.showImportModal = false;
|
|
state.showProfileModal = false;
|
|
state.editingPenalty = null;
|
|
state.editingPilot = null;
|
|
state.userModalForCompetition = null;
|
|
render();
|
|
}
|
|
|
|
async function openCompetition(id) {
|
|
const c = await API.getCompetition(id);
|
|
state.competition = c;
|
|
state.tab = "penalties";
|
|
await Promise.all([loadPilots(), loadPenalties(), loadMembers(), loadRules()]);
|
|
if (state.user.is_system_admin) await loadUsers();
|
|
if (state.ws) state.ws.close();
|
|
state.ws = openCompetitionWS(id, {
|
|
onopen: () => { state.wsOnline = true; updateStatus(); },
|
|
onclose: () => { state.wsOnline = false; updateStatus(); },
|
|
onmessage: handleWSMessage,
|
|
});
|
|
render();
|
|
}
|
|
|
|
function updateStatus() {
|
|
const el = document.querySelector(".connection-status");
|
|
if (!el) return;
|
|
el.classList.toggle("online", !!state.wsOnline);
|
|
el.classList.toggle("offline", !state.wsOnline);
|
|
}
|
|
|
|
function handleWSMessage(msg) {
|
|
if (msg.type === "penalty_created") {
|
|
const exists = state.penalties.some((p) => p.id === msg.payload.id);
|
|
if (!exists) {
|
|
state.penalties.unshift(msg.payload);
|
|
patchPenaltyTable();
|
|
}
|
|
} else if (msg.type === "penalty_updated") {
|
|
const idx = state.penalties.findIndex((p) => p.id === msg.payload.id);
|
|
if (idx >= 0) {
|
|
state.penalties[idx] = msg.payload;
|
|
patchPenaltyTable();
|
|
}
|
|
} else if (msg.type === "penalty_deleted") {
|
|
state.penalties = state.penalties.filter((p) => p.id !== msg.payload.id);
|
|
patchPenaltyTable();
|
|
} else if (msg.type === "pilot_changed") {
|
|
Promise.all([loadPilots(), loadPenalties()]).then(() => {
|
|
if (state.tab === "penalties") patchPenaltyTable();
|
|
else render();
|
|
});
|
|
} else if (msg.type === "competition_updated") {
|
|
API.getCompetition(state.competition.id).then((c) => { state.competition = c; render(); });
|
|
}
|
|
}
|
|
|
|
async function loadPilots() { state.pilots = await API.listPilots(state.competition.id); }
|
|
async function loadPenalties() { state.penalties = await API.listPenalties(state.competition.id); }
|
|
async function loadMembers() { state.members = await API.listMembers(state.competition.id); }
|
|
|
|
function isChief() {
|
|
const r = state.competition.role;
|
|
return r === "system_admin" || r === "chief_scorer";
|
|
}
|
|
|
|
function canEditPenalty(p) {
|
|
if (isChief()) return true;
|
|
if (p.created_by === state.user.id) return true;
|
|
return !!state.competition.allow_any_scorer_edit;
|
|
}
|
|
|
|
function renderCompetition() {
|
|
clear(root);
|
|
const backBtn = el("button", { class: "ghost", onclick: () => {
|
|
if (state.ws) { state.ws.close(); state.ws = null; }
|
|
state.competition = null;
|
|
render();
|
|
} }, "← " + t("back"));
|
|
root.appendChild(renderTopbar(backBtn));
|
|
|
|
const container = el("div", { class: "container" });
|
|
container.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.5rem" } },
|
|
el("h2", { style: { margin: 0 } },
|
|
state.competition.name,
|
|
" ",
|
|
el("span", { class: "badge accent" }, t(state.competition.role)),
|
|
),
|
|
el("div", { class: "row" },
|
|
el("span", { class: "connection-status " + (state.wsOnline ? "online" : "offline") }),
|
|
el("span", { class: "muted" }, state.wsOnline ? t("online") : t("offline")),
|
|
),
|
|
));
|
|
|
|
const tabs = el("div", { class: "tabs" });
|
|
const tabDefs = [
|
|
["penalties", t("penalties")],
|
|
["pilots", t("pilots")],
|
|
["rules", t("rules")],
|
|
];
|
|
if (isChief()) {
|
|
tabDefs.push(["members", t("members")]);
|
|
tabDefs.push(["settings", t("settings_tab")]);
|
|
}
|
|
for (const [id, label] of tabDefs) {
|
|
tabs.appendChild(el("button", {
|
|
class: state.tab === id ? "active" : "",
|
|
onclick: () => { state.tab = id; render(); }
|
|
}, label));
|
|
}
|
|
container.appendChild(tabs);
|
|
|
|
if (state.tab === "penalties") container.appendChild(renderPenaltiesTab());
|
|
else if (state.tab === "pilots") container.appendChild(renderPilotsTab());
|
|
else if (state.tab === "rules") container.appendChild(renderRulesTab());
|
|
else if (state.tab === "members") container.appendChild(renderMembersTab());
|
|
else if (state.tab === "settings") container.appendChild(renderSettingsTab());
|
|
|
|
root.appendChild(container);
|
|
|
|
if (state.showPenaltyModal) container.appendChild(renderPenaltyModal());
|
|
if (state.showPilotModal) container.appendChild(renderPilotModal());
|
|
if (state.showMemberModal) container.appendChild(renderMemberModal());
|
|
if (state.showUserModal) container.appendChild(renderUserModal());
|
|
if (state.showImportModal) container.appendChild(renderImportModal());
|
|
if (state.showProfileModal) container.appendChild(renderProfileModal());
|
|
}
|
|
|
|
const PENALTY_COLS = [
|
|
{ key: "id", label: "#" },
|
|
{ key: "flight", label: "flight" },
|
|
{ key: "date", label: "date" },
|
|
{ key: "pilot_number", label: "pilot_number" },
|
|
{ key: "pilot_name", label: "pilot_name" },
|
|
{ key: "rule_number", label: "rule_number_short" },
|
|
{ key: "task", label: "task" },
|
|
{ key: "penalties_text", label: "penalty_values" },
|
|
{ key: "description", label: "description" },
|
|
{ key: "created_by_name", label: "created_by" },
|
|
{ key: "transferred", label: "transferred" },
|
|
];
|
|
|
|
function sortPenalties(list) {
|
|
const { col, dir } = state.sort;
|
|
const sign = dir === "asc" ? 1 : -1;
|
|
return [...list].sort((a, b) => {
|
|
let av = a[col], bv = b[col];
|
|
if (typeof av === "boolean") av = av ? 1 : 0;
|
|
if (typeof bv === "boolean") bv = bv ? 1 : 0;
|
|
if (av == null) av = "";
|
|
if (bv == null) bv = "";
|
|
if (typeof av === "number" && typeof bv === "number") return (av - bv) * sign;
|
|
return String(av).localeCompare(String(bv), undefined, { numeric: true }) * sign;
|
|
});
|
|
}
|
|
|
|
function filterPenalties(list) {
|
|
if (state.filterUntransferred) list = list.filter((p) => !p.transferred);
|
|
return list;
|
|
}
|
|
|
|
function renderPenaltiesTab() {
|
|
const card = el("div");
|
|
|
|
const toolbar = el("div", { class: "toolbar" },
|
|
el("button", { class: "primary", onclick: () => openPenaltyModal() }, t("add_penalty")),
|
|
el("label", { class: "row" },
|
|
el("input", { type: "checkbox", checked: state.filterUntransferred,
|
|
onchange: (e) => { state.filterUntransferred = e.target.checked; patchPenaltyTable(); }
|
|
}),
|
|
" " + t("transferred_only")
|
|
),
|
|
el("div", { class: "spacer" }),
|
|
isChief() && el("a", { href: API.exportPenaltiesURL(state.competition.id), target: "_blank" },
|
|
el("button", null, t("export_csv"))
|
|
),
|
|
);
|
|
card.appendChild(toolbar);
|
|
|
|
const tableWrap = el("div", { class: "table-wrap", id: "penalty-table-wrap" });
|
|
const table = el("table", { id: "penalty-table" });
|
|
const thead = el("thead");
|
|
const headRow = el("tr");
|
|
for (const c of PENALTY_COLS) {
|
|
headRow.appendChild(el("th", {
|
|
onclick: () => {
|
|
if (state.sort.col === c.key) state.sort.dir = state.sort.dir === "asc" ? "desc" : "asc";
|
|
else { state.sort.col = c.key; state.sort.dir = "asc"; }
|
|
patchPenaltyTable();
|
|
}
|
|
},
|
|
t(c.label),
|
|
el("span", { class: "sort-ind" }, state.sort.col === c.key ? (state.sort.dir === "asc" ? "▲" : "▼") : ""),
|
|
));
|
|
}
|
|
headRow.appendChild(el("th", null, t("actions")));
|
|
thead.appendChild(headRow);
|
|
table.appendChild(thead);
|
|
|
|
const tbody = el("tbody", { id: "penalty-tbody" });
|
|
table.appendChild(tbody);
|
|
tableWrap.appendChild(table);
|
|
card.appendChild(tableWrap);
|
|
|
|
setTimeout(patchPenaltyTable, 0);
|
|
return card;
|
|
}
|
|
|
|
function patchPenaltyTable() {
|
|
const tbody = document.getElementById("penalty-tbody");
|
|
if (!tbody) return;
|
|
const list = sortPenalties(filterPenalties(state.penalties));
|
|
|
|
const headRow = document.querySelector("#penalty-table thead tr");
|
|
if (headRow) {
|
|
const ths = headRow.querySelectorAll("th");
|
|
PENALTY_COLS.forEach((c, i) => {
|
|
const ind = ths[i].querySelector(".sort-ind");
|
|
if (ind) ind.textContent = state.sort.col === c.key ? (state.sort.dir === "asc" ? "▲" : "▼") : "";
|
|
});
|
|
}
|
|
|
|
const existing = new Map();
|
|
for (const tr of Array.from(tbody.children)) {
|
|
existing.set(tr.dataset.id, tr);
|
|
}
|
|
const wanted = new Set();
|
|
let prev = null;
|
|
for (const p of list) {
|
|
const idStr = String(p.id);
|
|
wanted.add(idStr);
|
|
let tr = existing.get(idStr);
|
|
if (!tr) {
|
|
tr = buildPenaltyRow(p);
|
|
} else {
|
|
updatePenaltyRow(tr, p);
|
|
}
|
|
if (prev) {
|
|
if (prev.nextSibling !== tr) tbody.insertBefore(tr, prev.nextSibling);
|
|
} else {
|
|
if (tbody.firstChild !== tr) tbody.insertBefore(tr, tbody.firstChild);
|
|
}
|
|
prev = tr;
|
|
}
|
|
for (const [k, tr] of existing) {
|
|
if (!wanted.has(k)) tr.remove();
|
|
}
|
|
}
|
|
|
|
function buildPenaltyRow(p) {
|
|
const tr = document.createElement("tr");
|
|
tr.dataset.id = String(p.id);
|
|
for (const c of PENALTY_COLS) {
|
|
const td = document.createElement("td");
|
|
td.dataset.col = c.key;
|
|
tr.appendChild(td);
|
|
}
|
|
const actions = document.createElement("td");
|
|
actions.dataset.col = "_actions";
|
|
tr.appendChild(actions);
|
|
updatePenaltyRow(tr, p);
|
|
return tr;
|
|
}
|
|
|
|
function updatePenaltyRow(tr, p) {
|
|
tr.classList.toggle("transferred", !!p.transferred);
|
|
for (const c of PENALTY_COLS) {
|
|
const td = tr.querySelector(`td[data-col="${c.key}"]`);
|
|
if (!td) continue;
|
|
if (c.key === "transferred") {
|
|
td.innerHTML = "";
|
|
const lbl = document.createElement("label");
|
|
lbl.className = "switch";
|
|
const inp = document.createElement("input");
|
|
inp.type = "checkbox";
|
|
inp.checked = !!p.transferred;
|
|
inp.disabled = !canEditPenalty(p);
|
|
inp.addEventListener("change", async () => {
|
|
try {
|
|
await API.updatePenalty(state.competition.id, p.id, { transferred: inp.checked });
|
|
} catch (e) { inp.checked = !inp.checked; alert(e.message); }
|
|
});
|
|
const slider = document.createElement("span");
|
|
slider.className = "slider";
|
|
lbl.appendChild(inp);
|
|
lbl.appendChild(slider);
|
|
td.appendChild(lbl);
|
|
} else {
|
|
const val = p[c.key];
|
|
td.textContent = val == null ? "" : String(val);
|
|
}
|
|
}
|
|
const actions = tr.querySelector('td[data-col="_actions"]');
|
|
actions.innerHTML = "";
|
|
if (canEditPenalty(p)) {
|
|
const edit = document.createElement("button");
|
|
edit.className = "action-btn";
|
|
edit.textContent = t("edit");
|
|
edit.addEventListener("click", () => openPenaltyModal(p));
|
|
const del = document.createElement("button");
|
|
del.className = "action-btn danger";
|
|
del.textContent = t("delete");
|
|
del.addEventListener("click", async () => {
|
|
if (!confirm(t("confirm_delete"))) return;
|
|
await API.deletePenalty(state.competition.id, p.id);
|
|
});
|
|
actions.appendChild(edit);
|
|
actions.appendChild(del);
|
|
}
|
|
}
|
|
|
|
function openPenaltyModal(penalty) {
|
|
state.showPenaltyModal = true;
|
|
state.editingPenalty = penalty || null;
|
|
render();
|
|
}
|
|
|
|
function renderPenaltyModal() {
|
|
const p = state.editingPenalty || {};
|
|
const flight = el("input", { type: "text", value: p.flight || "" });
|
|
const date = el("input", { type: "date", value: p.date || new Date().toISOString().slice(0, 10) });
|
|
const pilotSelect = el("select", { onchange: () => refreshPrior() },
|
|
el("option", { value: "" }, "—"),
|
|
...sortPilots(state.pilots).map((pl) => el("option", { value: pl.number, selected: pl.number === p.pilot_number },
|
|
`${pl.number} — ${pl.last_name}, ${pl.first_name}`))
|
|
);
|
|
const existingRule = p.rule_number ? state.rulesByNumber[p.rule_number] : null;
|
|
const ruleSearch = el("input", { type: "text", placeholder: t("search_rule"),
|
|
value: existingRule ? `${existingRule.number} — ${existingRule.text}` : (p.rule_number || "") });
|
|
const ruleNumber = el("input", { type: "text", placeholder: t("rule_number_short"), value: p.rule_number || "" });
|
|
const suggestionBox = el("div", { class: "rule-card", style: { display: "none" } });
|
|
const searchResults = el("div", { class: "results", style: { display: "none" } });
|
|
const priorBox = el("div", { class: "prior-box", style: { display: "none" } });
|
|
|
|
function escalationViz(r) {
|
|
if (r.escalation_mode === "same") return el("span", { class: "muted small" }, t("escalation_same"));
|
|
if (r.escalation_mode === "doubled") return el("span", { class: "muted small" }, t("escalation_doubled"));
|
|
if (r.escalation_mode === "escalate") {
|
|
const wrap = el("div", { class: "tier-row" });
|
|
const tiers = r.escalation_tiers || [];
|
|
tiers.forEach((tier, i) => {
|
|
wrap.appendChild(el("span", { class: "tier-pill" }, tier));
|
|
if (i < tiers.length - 1) wrap.appendChild(el("span", { class: "tier-arrow" }, "→"));
|
|
});
|
|
return wrap;
|
|
}
|
|
return el("span", null, "");
|
|
}
|
|
|
|
function refreshSuggestion() {
|
|
const rNum = ruleNumber.value.trim();
|
|
const r = state.rulesByNumber[rNum];
|
|
if (!r) { suggestionBox.style.display = "none"; return; }
|
|
suggestionBox.style.display = "block";
|
|
suggestionBox.innerHTML = "";
|
|
suggestionBox.appendChild(el("div", { class: "rule-head" },
|
|
el("span", { class: "rule-num" }, r.number),
|
|
el("span", { class: "rule-text" }, r.text),
|
|
));
|
|
suggestionBox.appendChild(el("div", { class: "kv" },
|
|
el("span", { class: "k" }, t("suggested_penalty")),
|
|
el("span", { class: "badge accent" }, r.suggested_penalty),
|
|
));
|
|
suggestionBox.appendChild(el("div", { class: "kv" },
|
|
el("span", { class: "k" }, t("escalation")),
|
|
escalationViz(r),
|
|
));
|
|
}
|
|
|
|
function refreshPrior() {
|
|
const rNum = ruleNumber.value.trim();
|
|
const pNum = pilotSelect.value;
|
|
if (!rNum || !pNum) { priorBox.style.display = "none"; return; }
|
|
const prior = state.penalties.filter((x) =>
|
|
x.pilot_number === pNum && x.rule_number === rNum && x.id !== p.id
|
|
);
|
|
priorBox.innerHTML = "";
|
|
if (prior.length === 0) { priorBox.style.display = "none"; return; }
|
|
priorBox.style.display = "block";
|
|
priorBox.appendChild(el("div", { class: "prior-title" },
|
|
t("prior_penalties") + " (" + prior.length + ")"
|
|
));
|
|
const tbl = el("table", { class: "mini-table" });
|
|
tbl.appendChild(el("thead", null, el("tr", null,
|
|
el("th", null, t("flight")),
|
|
el("th", null, t("date")),
|
|
el("th", null, t("penalty_values")),
|
|
el("th", null, t("description")),
|
|
)));
|
|
const tb = el("tbody");
|
|
prior.sort((a, b) => naturalCompare(a.date, b.date) || (a.id - b.id));
|
|
for (const pp of prior) {
|
|
tb.appendChild(el("tr", null,
|
|
el("td", null, pp.flight),
|
|
el("td", null, pp.date),
|
|
el("td", null, el("span", { class: "badge accent" }, pp.penalties_text)),
|
|
el("td", null, pp.description),
|
|
));
|
|
}
|
|
tbl.appendChild(tb);
|
|
priorBox.appendChild(tbl);
|
|
}
|
|
|
|
refreshSuggestion();
|
|
refreshPrior();
|
|
|
|
ruleNumber.addEventListener("input", () => { refreshSuggestion(); refreshPrior(); });
|
|
|
|
function searchRules(q) {
|
|
q = q.trim().toLowerCase();
|
|
if (!q) return [];
|
|
return state.rules.filter((r) =>
|
|
r.number.toLowerCase().includes(q) || r.text.toLowerCase().includes(q)
|
|
).slice(0, 30);
|
|
}
|
|
ruleSearch.addEventListener("input", () => {
|
|
const items = searchRules(ruleSearch.value);
|
|
searchResults.innerHTML = "";
|
|
if (items.length === 0) { searchResults.style.display = "none"; return; }
|
|
searchResults.style.display = "block";
|
|
for (const r of items) {
|
|
const it = el("div", { class: "item",
|
|
onclick: () => {
|
|
ruleNumber.value = r.number;
|
|
ruleSearch.value = `${r.number} — ${r.text}`;
|
|
searchResults.style.display = "none";
|
|
refreshSuggestion();
|
|
refreshPrior();
|
|
}
|
|
},
|
|
el("span", { class: "num" }, r.number),
|
|
r.text
|
|
);
|
|
searchResults.appendChild(it);
|
|
}
|
|
});
|
|
ruleSearch.addEventListener("blur", () => setTimeout(() => searchResults.style.display = "none", 200));
|
|
|
|
const task = el("input", { type: "text", value: p.task || "" });
|
|
const penaltiesText = el("input", { type: "text", value: p.penalties_text || "",
|
|
placeholder: "e.g. 50 CP, Warning, +50m, No Result" });
|
|
const description = el("textarea", null, p.description || "");
|
|
const transferred = el("input", { type: "checkbox", checked: !!p.transferred });
|
|
|
|
return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) closeAll(); } },
|
|
el("div", { class: "modal" },
|
|
el("h3", null, p.id ? t("edit") : t("add_penalty")),
|
|
el("div", { class: "grid" },
|
|
el("div", { class: "field" }, el("label", null, t("flight")), flight),
|
|
el("div", { class: "field" }, el("label", null, t("date")), date),
|
|
el("div", { class: "field" }, el("label", null, t("pilot_number")), pilotSelect),
|
|
),
|
|
el("div", { class: "field", style: { marginTop: "0.5rem" } },
|
|
el("label", null, t("search_rule")),
|
|
el("div", { class: "search-box" }, ruleSearch, searchResults),
|
|
),
|
|
el("div", { class: "field" }, el("label", null, t("rule_number_short")), ruleNumber),
|
|
suggestionBox,
|
|
priorBox,
|
|
el("div", { class: "grid", style: { marginTop: "0.5rem" } },
|
|
el("div", { class: "field" }, el("label", null, t("task")), task),
|
|
el("div", { class: "field" }, el("label", null, t("penalty_values")), penaltiesText),
|
|
),
|
|
el("div", { class: "field" }, el("label", null, t("description")), description),
|
|
el("label", { class: "row", style: { marginTop: "0.5rem" } }, transferred, " " + t("transferred")),
|
|
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
|
|
el("button", { onclick: closeAll }, t("cancel")),
|
|
el("button", { class: "primary", onclick: async () => {
|
|
const body = {
|
|
flight: flight.value,
|
|
date: date.value,
|
|
pilot_number: pilotSelect.value,
|
|
rule_number: ruleNumber.value,
|
|
task: task.value,
|
|
penalties_text: penaltiesText.value,
|
|
description: description.value,
|
|
transferred: transferred.checked,
|
|
};
|
|
try {
|
|
if (p.id) {
|
|
await API.updatePenalty(state.competition.id, p.id, body);
|
|
} else {
|
|
await API.createPenalty(state.competition.id, body);
|
|
}
|
|
closeAll();
|
|
} catch (e) { alert(e.message); }
|
|
} }, t("save")),
|
|
),
|
|
)
|
|
);
|
|
}
|
|
|
|
const PILOT_COLS = [
|
|
{ key: "number", label: "number" },
|
|
{ key: "last_name", label: "last_name" },
|
|
{ key: "first_name", label: "first_name" },
|
|
{ key: "country", label: "country" },
|
|
{ key: "balloon_id", label: "balloon_id" },
|
|
];
|
|
|
|
function naturalCompare(a, b) {
|
|
if (a == null) a = "";
|
|
if (b == null) b = "";
|
|
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" });
|
|
}
|
|
|
|
function sortPilots(list) {
|
|
const { col, dir } = state.pilotSort;
|
|
const sign = dir === "asc" ? 1 : -1;
|
|
return [...list].sort((a, b) => naturalCompare(a[col], b[col]) * sign);
|
|
}
|
|
|
|
function renderPilotsTab() {
|
|
const card = el("div");
|
|
if (isChief()) {
|
|
card.appendChild(el("div", { class: "toolbar" },
|
|
el("button", { class: "primary", onclick: () => openPilotModal() }, t("add_pilot")),
|
|
el("button", { onclick: () => openImportModal() }, t("import_csv")),
|
|
));
|
|
}
|
|
if (state.pilots.length === 0) {
|
|
card.appendChild(el("div", { class: "muted" }, t("no_pilots")));
|
|
return card;
|
|
}
|
|
const table = el("table");
|
|
const headRow = el("tr");
|
|
for (const c of PILOT_COLS) {
|
|
headRow.appendChild(el("th", {
|
|
onclick: () => {
|
|
if (state.pilotSort.col === c.key) state.pilotSort.dir = state.pilotSort.dir === "asc" ? "desc" : "asc";
|
|
else { state.pilotSort.col = c.key; state.pilotSort.dir = "asc"; }
|
|
render();
|
|
}
|
|
},
|
|
t(c.label),
|
|
el("span", { class: "sort-ind" }, state.pilotSort.col === c.key ? (state.pilotSort.dir === "asc" ? "▲" : "▼") : ""),
|
|
));
|
|
}
|
|
if (isChief()) headRow.appendChild(el("th", null, t("actions")));
|
|
table.appendChild(el("thead", null, headRow));
|
|
const tbody = el("tbody");
|
|
const sorted = sortPilots(state.pilots);
|
|
for (const pl of sorted) {
|
|
tbody.appendChild(el("tr", null,
|
|
el("td", null, pl.number),
|
|
el("td", null, pl.last_name),
|
|
el("td", null, pl.first_name),
|
|
el("td", null, pl.country),
|
|
el("td", null, pl.balloon_id),
|
|
isChief() && el("td", null,
|
|
el("button", { class: "action-btn", onclick: () => openPilotModal(pl) }, t("edit")),
|
|
el("button", { class: "action-btn danger", onclick: async () => {
|
|
if (!confirm(t("confirm_delete"))) return;
|
|
await API.deletePilot(state.competition.id, pl.id);
|
|
await loadPilots();
|
|
render();
|
|
} }, t("delete")),
|
|
),
|
|
));
|
|
}
|
|
table.appendChild(tbody);
|
|
card.appendChild(el("div", { class: "table-wrap" }, table));
|
|
return card;
|
|
}
|
|
|
|
function openPilotModal(pilot) {
|
|
state.showPilotModal = true;
|
|
state.editingPilot = pilot || null;
|
|
render();
|
|
}
|
|
|
|
function renderPilotModal() {
|
|
const p = state.editingPilot || {};
|
|
const number = el("input", { type: "text", value: p.number || "" });
|
|
const lastName = el("input", { type: "text", value: p.last_name || "" });
|
|
const firstName = el("input", { type: "text", value: p.first_name || "" });
|
|
const country = el("input", { type: "text", value: p.country || "" });
|
|
const balloon = el("input", { type: "text", value: p.balloon_id || "" });
|
|
return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) closeAll(); } },
|
|
el("div", { class: "modal" },
|
|
el("h3", null, p.id ? t("edit") : t("add_pilot")),
|
|
el("div", { class: "grid" },
|
|
el("div", { class: "field" }, el("label", null, t("number")), number),
|
|
el("div", { class: "field" }, el("label", null, t("last_name")), lastName),
|
|
el("div", { class: "field" }, el("label", null, t("first_name")), firstName),
|
|
el("div", { class: "field" }, el("label", null, t("country")), country),
|
|
el("div", { class: "field" }, el("label", null, t("balloon_id")), balloon),
|
|
),
|
|
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
|
|
el("button", { onclick: closeAll }, t("cancel")),
|
|
el("button", { class: "primary", onclick: async () => {
|
|
const body = {
|
|
number: number.value.trim(),
|
|
last_name: lastName.value.trim(),
|
|
first_name: firstName.value.trim(),
|
|
country: country.value.trim(),
|
|
balloon_id: balloon.value.trim(),
|
|
};
|
|
try {
|
|
if (p.id) await API.updatePilot(state.competition.id, p.id, body);
|
|
else await API.createPilot(state.competition.id, body);
|
|
await loadPilots();
|
|
closeAll();
|
|
} catch (e) { alert(e.message); }
|
|
} }, t("save")),
|
|
),
|
|
)
|
|
);
|
|
}
|
|
|
|
function openImportModal() { state.showImportModal = true; render(); }
|
|
|
|
function renderImportModal() {
|
|
const textarea = el("textarea", { placeholder: t("csv_paste"), style: { width: "100%", minHeight: "180px" } });
|
|
return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) { state.showImportModal = false; render(); } } },
|
|
el("div", { class: "modal" },
|
|
el("h3", null, t("import_csv")),
|
|
textarea,
|
|
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
|
|
el("button", { onclick: () => { state.showImportModal = false; render(); } }, t("cancel")),
|
|
el("button", { class: "primary", onclick: async () => {
|
|
try {
|
|
await API.importPilots(state.competition.id, textarea.value);
|
|
await loadPilots();
|
|
state.showImportModal = false; render();
|
|
} catch (e) { alert(e.message); }
|
|
} }, t("import_csv")),
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
function ruleEscalationViz(r) {
|
|
if (r.escalation_mode === "same") return el("span", { class: "muted small" }, t("escalation_same"));
|
|
if (r.escalation_mode === "doubled") return el("span", { class: "muted small" }, t("escalation_doubled"));
|
|
if (r.escalation_mode === "escalate") {
|
|
const wrap = el("div", { class: "tier-row" });
|
|
const tiers = r.escalation_tiers || [];
|
|
tiers.forEach((tier, i) => {
|
|
wrap.appendChild(el("span", { class: "tier-pill" }, tier));
|
|
if (i < tiers.length - 1) wrap.appendChild(el("span", { class: "tier-arrow" }, "→"));
|
|
});
|
|
return wrap;
|
|
}
|
|
return el("span", null, "");
|
|
}
|
|
|
|
function renderRulesTab() {
|
|
const card = el("div");
|
|
const search = el("input", { type: "search", placeholder: t("search_rule"), style: { width: "100%" } });
|
|
const count = el("div", { class: "muted", style: { margin: "0.5rem 0" } });
|
|
const list = el("div", { class: "rules-list" });
|
|
|
|
function refresh() {
|
|
list.innerHTML = "";
|
|
const q = search.value.trim().toLowerCase();
|
|
const filtered = q
|
|
? state.rules.filter((r) => r.number.toLowerCase().includes(q) || r.text.toLowerCase().includes(q))
|
|
: state.rules;
|
|
filtered.sort((a, b) => naturalCompare(a.number, b.number));
|
|
count.textContent = t("showing_n_of_m", { n: filtered.length, m: state.rules.length });
|
|
for (const r of filtered) {
|
|
list.appendChild(el("div", { class: "rule-card" },
|
|
el("div", { class: "rule-head" },
|
|
el("span", { class: "rule-num" }, r.number),
|
|
el("span", { class: "rule-text" }, r.text),
|
|
),
|
|
el("div", { class: "kv" },
|
|
el("span", { class: "k" }, t("suggested_penalty")),
|
|
el("span", { class: "badge accent" }, r.suggested_penalty),
|
|
),
|
|
el("div", { class: "kv" },
|
|
el("span", { class: "k" }, t("escalation")),
|
|
ruleEscalationViz(r),
|
|
),
|
|
));
|
|
}
|
|
if (filtered.length === 0) list.appendChild(el("div", { class: "muted" }, t("none")));
|
|
}
|
|
search.addEventListener("input", refresh);
|
|
card.appendChild(search);
|
|
card.appendChild(count);
|
|
card.appendChild(list);
|
|
setTimeout(refresh, 0);
|
|
return card;
|
|
}
|
|
|
|
function renderMembersTab() {
|
|
const card = el("div");
|
|
card.appendChild(el("div", { class: "toolbar" },
|
|
el("button", { class: "primary", onclick: () => openMemberModal() }, t("add_member")),
|
|
state.user.is_system_admin && el("button", { onclick: () => openUserModal(state.competition.id) }, t("add_user")),
|
|
));
|
|
if (state.members.length === 0) {
|
|
card.appendChild(el("div", { class: "muted" }, t("no_members")));
|
|
return card;
|
|
}
|
|
const table = el("table");
|
|
table.appendChild(el("thead", null, el("tr", null,
|
|
el("th", null, t("username")),
|
|
el("th", null, t("display_name")),
|
|
el("th", null, t("role")),
|
|
el("th", null, t("actions")),
|
|
)));
|
|
const tbody = el("tbody");
|
|
for (const m of state.members) {
|
|
tbody.appendChild(el("tr", null,
|
|
el("td", null, m.username),
|
|
el("td", null, m.display_name),
|
|
el("td", null, t(m.role)),
|
|
el("td", null,
|
|
el("button", { class: "action-btn danger", onclick: async () => {
|
|
if (!confirm(t("confirm_delete"))) return;
|
|
await API.removeMember(state.competition.id, m.user_id);
|
|
await loadMembers();
|
|
render();
|
|
} }, t("remove")),
|
|
),
|
|
));
|
|
}
|
|
table.appendChild(tbody);
|
|
card.appendChild(el("div", { class: "table-wrap" }, table));
|
|
return card;
|
|
}
|
|
|
|
function openMemberModal() {
|
|
state.showMemberModal = true;
|
|
render();
|
|
}
|
|
|
|
function renderMemberModal() {
|
|
const username = el("input", { type: "text", placeholder: t("username") });
|
|
const roleSelect = el("select", null,
|
|
el("option", { value: "scorer" }, t("scorer")),
|
|
el("option", { value: "chief_scorer" }, t("chief_scorer")),
|
|
);
|
|
return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) closeAll(); } },
|
|
el("div", { class: "modal" },
|
|
el("h3", null, t("add_member")),
|
|
el("p", { class: "muted" }, t("username")),
|
|
username,
|
|
el("div", { class: "field", style: { marginTop: "0.5rem" } }, el("label", null, t("role")), roleSelect),
|
|
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
|
|
el("button", { onclick: closeAll }, t("cancel")),
|
|
el("button", { class: "primary", onclick: async () => {
|
|
let users = [];
|
|
try { users = await API.listUsers(); } catch (e) { alert(t("forbidden")); return; }
|
|
const u = users.find((x) => x.username === username.value.trim());
|
|
if (!u) { alert("User not found"); return; }
|
|
await API.addMember(state.competition.id, { user_id: u.id, role: roleSelect.value });
|
|
await loadMembers();
|
|
closeAll();
|
|
} }, t("save")),
|
|
),
|
|
)
|
|
);
|
|
}
|
|
|
|
function renderSettingsTab() {
|
|
const name = el("input", { type: "text", value: state.competition.name });
|
|
const allow = el("input", { type: "checkbox", checked: !!state.competition.allow_any_scorer_edit });
|
|
const msg = el("div", { class: "muted", style: { color: "var(--accent)", display: "none" } });
|
|
return el("div", { class: "card" },
|
|
el("h2", null, t("settings_tab")),
|
|
el("div", { class: "field" }, el("label", null, t("competition_name")), name),
|
|
el("label", { class: "row", style: { marginTop: "0.5rem" } }, allow, " " + t("allow_any_scorer_edit")),
|
|
msg,
|
|
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
|
|
el("button", { class: "primary", onclick: async () => {
|
|
await API.updateCompetition(state.competition.id, {
|
|
name: name.value, allow_any_scorer_edit: allow.checked,
|
|
});
|
|
const c = await API.getCompetition(state.competition.id);
|
|
state.competition = c;
|
|
msg.textContent = t("saved");
|
|
msg.style.display = "block";
|
|
} }, t("save_settings")),
|
|
),
|
|
);
|
|
}
|
|
|
|
async function init() {
|
|
const userLang = navigator.language ? navigator.language.slice(0, 2) : "en";
|
|
setLang(I18N_AVAILABLE.includes(userLang) ? userLang : "en");
|
|
try {
|
|
const u = await API.me();
|
|
state.user = u;
|
|
setLang(u.language);
|
|
await loadCompetitions();
|
|
if (u.is_system_admin) await loadUsers();
|
|
} catch (e) {
|
|
state.user = null;
|
|
}
|
|
render();
|
|
}
|
|
|
|
init();
|