Add basic web app and API integration
This commit is contained in:
+96
@@ -0,0 +1,96 @@
|
||||
async function api(method, path, body) {
|
||||
const opts = {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
};
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(apiURL(path), opts);
|
||||
if (res.status === 204) return null;
|
||||
let data = null;
|
||||
const text = await res.text();
|
||||
if (text) {
|
||||
try { data = JSON.parse(text); } catch (e) { data = text; }
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = new Error((data && data.error) || res.statusText);
|
||||
err.status = res.status;
|
||||
err.data = data;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
const API = {
|
||||
login: (u, p) => api("POST", "/api/login", { username: u, password: p }),
|
||||
logout: () => api("POST", "/api/logout"),
|
||||
me: () => api("GET", "/api/me"),
|
||||
updateMe: (b) => api("PATCH", "/api/me", b),
|
||||
|
||||
listUsers: () => api("GET", "/api/users"),
|
||||
createUser: (b) => api("POST", "/api/users", b),
|
||||
deleteUser: (id) => api("DELETE", `/api/users/${id}`),
|
||||
|
||||
listCompetitions: () => api("GET", "/api/competitions"),
|
||||
createCompetition: (b) => api("POST", "/api/competitions", b),
|
||||
getCompetition: (id) => api("GET", `/api/competitions/${id}`),
|
||||
updateCompetition: (id, b) => api("PATCH", `/api/competitions/${id}`, b),
|
||||
deleteCompetition: (id) => api("DELETE", `/api/competitions/${id}`),
|
||||
|
||||
listMembers: (id) => api("GET", `/api/competitions/${id}/members`),
|
||||
addMember: (id, b) => api("POST", `/api/competitions/${id}/members`, b),
|
||||
removeMember: (id, uid) => api("DELETE", `/api/competitions/${id}/members/${uid}`),
|
||||
|
||||
listPilots: (id) => api("GET", `/api/competitions/${id}/pilots`),
|
||||
createPilot: (id, b) => api("POST", `/api/competitions/${id}/pilots`, b),
|
||||
updatePilot: (id, pid, b) => api("PATCH", `/api/competitions/${id}/pilots/${pid}`, b),
|
||||
deletePilot: (id, pid) => api("DELETE", `/api/competitions/${id}/pilots/${pid}`),
|
||||
importPilots: async (id, csv) => {
|
||||
const res = await fetch(apiURL(`/api/competitions/${id}/pilots/import`), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "text/csv" },
|
||||
body: csv,
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error("import_failed");
|
||||
return res.json();
|
||||
},
|
||||
|
||||
listPenalties: (id) => api("GET", `/api/competitions/${id}/penalties`),
|
||||
createPenalty: (id, b) => api("POST", `/api/competitions/${id}/penalties`, b),
|
||||
updatePenalty: (id, pid, b) => api("PATCH", `/api/competitions/${id}/penalties/${pid}`, b),
|
||||
deletePenalty: (id, pid) => api("DELETE", `/api/competitions/${id}/penalties/${pid}`),
|
||||
exportPenaltiesURL: (id) => apiURL(`/api/competitions/${id}/penalties.csv`),
|
||||
|
||||
listRules: (lang) => api("GET", `/api/rules${lang ? "?lang=" + encodeURIComponent(lang) : ""}`),
|
||||
};
|
||||
|
||||
function openCompetitionWS(id, handlers) {
|
||||
const url = wsURL(`/api/competitions/${id}/ws`);
|
||||
let ws = null;
|
||||
let closed = false;
|
||||
let backoff = 1000;
|
||||
|
||||
function connect() {
|
||||
ws = new WebSocket(url);
|
||||
ws.onopen = () => { backoff = 1000; if (handlers.onopen) handlers.onopen(); };
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (handlers.onmessage) handlers.onmessage(msg);
|
||||
} catch (err) {}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
if (handlers.onclose) handlers.onclose();
|
||||
if (!closed) {
|
||||
setTimeout(connect, backoff);
|
||||
backoff = Math.min(backoff * 2, 15000);
|
||||
}
|
||||
};
|
||||
ws.onerror = () => { try { ws.close(); } catch (e) {} };
|
||||
}
|
||||
connect();
|
||||
return {
|
||||
close: () => { closed = true; try { ws && ws.close(); } catch (e) {} }
|
||||
};
|
||||
}
|
||||
+1163
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
const API_BASE = "http://127.0.0.1:8080";
|
||||
|
||||
function getApiBase() {
|
||||
return API_BASE;
|
||||
}
|
||||
|
||||
function apiURL(path) {
|
||||
const base = getApiBase();
|
||||
if (!base) return path;
|
||||
return base + path;
|
||||
}
|
||||
|
||||
function wsURL(path) {
|
||||
const base = getApiBase();
|
||||
if (base) {
|
||||
let u;
|
||||
try { u = new URL(base); } catch (e) { return null; }
|
||||
u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
|
||||
u.pathname = u.pathname.replace(/\/+$/, "") + path;
|
||||
return u.toString();
|
||||
}
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${proto}//${location.host}${path}`;
|
||||
}
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
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_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: "Balloon ID",
|
||||
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,balloon)",
|
||||
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",
|
||||
},
|
||||
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: "Ballon-Kennung",
|
||||
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,Ballon)",
|
||||
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",
|
||||
},
|
||||
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: "Oznaczenie balonu",
|
||||
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,balon)",
|
||||
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: "Регистрация шара",
|
||||
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 (№,фамилия,имя,страна,шар)",
|
||||
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: "Immat. ballon",
|
||||
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,ballon)",
|
||||
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: "Matrícula globo",
|
||||
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,globo)",
|
||||
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: "Matrícula balão",
|
||||
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,balão)",
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
let CURRENT_LANG = "en";
|
||||
|
||||
function setLang(lang) {
|
||||
if (!I18N_DATA[lang]) lang = "en";
|
||||
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;
|
||||
if (vars) {
|
||||
for (const k in vars) {
|
||||
str = str.replaceAll("{" + k + "}", vars[k]);
|
||||
}
|
||||
}
|
||||
return str;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Penalty Tracker</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="/config.js"></script>
|
||||
<script src="/i18n.js"></script>
|
||||
<script src="/api.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
:root {
|
||||
--accent: #2b6cb0;
|
||||
--accent-hover: #2c5282;
|
||||
--bg: #ffffff;
|
||||
--fg: #111111;
|
||||
--muted: #6b7280;
|
||||
--border: #e5e7eb;
|
||||
--row-hover: #f9fafb;
|
||||
--danger: #b91c1c;
|
||||
--radius: 0.375rem;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { color: var(--accent-hover); text-decoration: underline; }
|
||||
|
||||
button, input, select, textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
padding: 0.4rem 0.6rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
transition: background 0.1s ease, border-color 0.1s ease;
|
||||
}
|
||||
button:hover { border-color: var(--accent); }
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
button.primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||
button.danger {
|
||||
background: #fff;
|
||||
color: var(--danger);
|
||||
border-color: var(--border);
|
||||
}
|
||||
button.danger:hover { border-color: var(--danger); }
|
||||
button.ghost { border-color: transparent; background: transparent; }
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(43, 108, 176, 0.15);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #fff;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.topbar .brand {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
.topbar .nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 1.25rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
background: #fff;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.row { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
|
||||
.col { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.field label { font-size: 0.8rem; color: var(--muted); }
|
||||
|
||||
.login-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 1rem;
|
||||
}
|
||||
.login-box {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
background: #fff;
|
||||
}
|
||||
.login-box h1 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.125rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
font-weight: 600;
|
||||
background: #fafafa;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
th .sort-ind {
|
||||
color: var(--muted);
|
||||
font-size: 0.7rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
tbody tr:hover { background: var(--row-hover); }
|
||||
tbody tr.transferred { background: #f3f6fb; }
|
||||
|
||||
.table-wrap {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: auto;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.75rem;
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.badge.accent { background: rgba(43, 108, 176, 0.1); color: var(--accent); border-color: rgba(43, 108, 176, 0.3); }
|
||||
.badge.warn { background: #fff7ed; color: #9a3412; border-color: #fed7aa; }
|
||||
|
||||
.muted { color: var(--muted); font-size: 0.85rem; }
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.4);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 50;
|
||||
padding: 1rem;
|
||||
}
|
||||
.modal {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.modal h3 { margin-top: 0; }
|
||||
|
||||
.toolbar { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; flex-wrap: wrap; }
|
||||
.toolbar .spacer { flex: 1; }
|
||||
|
||||
.rule-suggestion {
|
||||
background: #f9fafb;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.rule-card {
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.rule-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.rule-num {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.85rem;
|
||||
background: rgba(43, 108, 176, 0.08);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: var(--radius);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rule-text { font-size: 0.9rem; line-height: 1.3; }
|
||||
.kv {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.kv .k { color: var(--muted); min-width: 9rem; }
|
||||
.small { font-size: 0.8rem; }
|
||||
|
||||
.tier-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tier-pill {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg);
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tier-pill:last-child {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: rgba(43, 108, 176, 0.08);
|
||||
}
|
||||
.tier-arrow { color: var(--muted); font-size: 0.75rem; }
|
||||
|
||||
.rules-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.prior-box {
|
||||
margin-top: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #fafbfc;
|
||||
}
|
||||
.prior-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.mini-table { font-size: 12px; }
|
||||
.mini-table th, .mini-table td {
|
||||
padding: 0.3rem 0.4rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: transparent;
|
||||
}
|
||||
.mini-table th { background: transparent; position: static; }
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.search-box .results {
|
||||
position: absolute;
|
||||
top: 100%; left: 0; right: 0;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
z-index: 5;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.search-box .results .item {
|
||||
padding: 0.5rem 0.6rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.search-box .results .item:last-child { border-bottom: none; }
|
||||
.search-box .results .item:hover { background: #f3f4f6; }
|
||||
.search-box .results .item .num { color: var(--accent); font-weight: 600; margin-right: 0.5rem; }
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
}
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.switch .slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background-color: #d1d5db;
|
||||
transition: 0.15s;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.switch .slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
left: 3px;
|
||||
top: 3px;
|
||||
background-color: white;
|
||||
transition: 0.15s;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.switch input:checked + .slider { background-color: var(--accent); }
|
||||
.switch input:checked + .slider:before { transform: translateX(16px); }
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.tabs button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.tabs button.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.action-btn:hover { border-color: var(--border); }
|
||||
|
||||
.connection-status {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #d1d5db;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
.connection-status.online { background: #10b981; }
|
||||
.connection-status.offline { background: #ef4444; }
|
||||
|
||||
textarea { resize: vertical; min-height: 60px; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.topbar { padding: 0.5rem 0.75rem; }
|
||||
.container { padding: 0.75rem; }
|
||||
}
|
||||
Reference in New Issue
Block a user