diff --git a/app.js b/app.js index 4b44601..d91f9a1 100644 --- a/app.js +++ b/app.js @@ -158,8 +158,9 @@ const LANG = { prev_week: "Vorige Woche", next_week: "Nächste Woche", week_of: "Woche vom", wizard_done: "Alles erledigt - die App ist bereit.", wizard_user_created: "Benutzer '{u}' angelegt.", - wizard_lang_hint: "Bereit loszulegen? Anleitung, Demo-Daten und der Übungsplan stehen im Repo:", - wizard_user_later: "Der Benutzer wird beim Abschließen angelegt - du kannst dich im Wizard bis dahin umentscheiden.", + wizard_lang_hint: "Anleitung, Demo-Daten und der Übungsplan stehen im Repo.", + wizard_user_later: "Der Benutzer wird beim Abschließen angelegt - bis dahin kannst du dich umentscheiden.", + wizard_admin_hint: "Der erste Benutzer wird Administrator (verwaltet Konten und Kennwörter).", logged_in: "Angemeldet.", user_created_toast: "Benutzer angelegt.", app_load_fail: "App konnte nicht geladen werden: {m}", }, @@ -275,8 +276,9 @@ const LANG = { prev_week: "Previous week", next_week: "Next week", week_of: "Week of", wizard_done: "All done - the app is ready.", wizard_user_created: "User '{u}' created.", - wizard_lang_hint: "Ready to get started? The guide, demo data and the exercise plan are in the repo:", + wizard_lang_hint: "Guide, demo data and the exercise plan live in the repo.", wizard_user_later: "The user will be created when you finish - you can still change your mind until then.", + wizard_admin_hint: "The first user becomes the administrator (manages accounts and passwords).", logged_in: "Logged in.", user_created_toast: "User created.", app_load_fail: "Could not load the app: {m}", }, @@ -908,7 +910,7 @@ function buildSetRow(a, model, ex, entry, i) { chk.type = "button"; chk.setAttribute("aria-pressed", entry.done ? "true" : "false"); chk.setAttribute("aria-label", t("set_done_aria", { n: i + 1 })); - chk.appendChild(el("span", "set-check-label", "Set")); + chk.appendChild(el("span", "set-check-label", "S")); chk.appendChild(el("span", "set-check-num", String(i + 1))); chk.appendChild(el("span", "set-check-box", entry.done ? "✓" : "")); chk.addEventListener("click", () => toggleSetDone(a, model, ex.key, i, chk)); @@ -930,7 +932,7 @@ function buildSetRow(a, model, ex, entry, i) { entry.wInput = wBox.querySelector("input"); entry.rInput = rBox.querySelector("input"); - const reset = el("button", "btn btn-small", t("reset_set")); + const reset = el("button", "icon-btn", resetSvg()); reset.type = "button"; reset.setAttribute("aria-label", t("reset_set_title")); reset.addEventListener("click", () => resetSet(a, model, ex.key, i, entry)); @@ -945,6 +947,15 @@ function buildSetRow(a, model, ex, entry, i) { return row; } +function resetSvg() { + const svg = el("span", ""); + svg.innerHTML = ''; + return svg; +} + function makeFieldBox(label, unit, value, onChange, inputmode) { const box = el("label", "field-box"); box.setAttribute("aria-label", label + " in " + unit); @@ -1754,12 +1765,14 @@ function renderWizardStep() { row.appendChild(b); } body.appendChild(row); - body.appendChild(el("p", "auth-sub", t("wizard_lang_hint"))); - const repo = el("a", "btn", "git.somantis.net/J4stMarkus/trainingsplan"); - repo.href = "https://git.somantis.net/J4stMarkus/trainingsplan"; - repo.target = "_blank"; - repo.rel = "noopener"; - body.appendChild(repo); + if (wizard.mode === "setup") { + body.appendChild(el("p", "auth-sub", t("wizard_lang_hint"))); + const repo = el("a", "btn", "git.somantis.net/J4stMarkus/trainingsplan"); + repo.href = "https://git.somantis.net/J4stMarkus/trainingsplan"; + repo.target = "_blank"; + repo.rel = "noopener"; + body.appendChild(repo); + } } else if (wizard.step === 1) { // Schritt 1: Darstellung (Theme + OLED + Akzent) body.appendChild(el("h3", "wizard-title", t("appearance"))); @@ -1816,6 +1829,7 @@ function renderWizardStep() { form.appendChild(authField(t("username"), "setup-user")); form.appendChild(authField(t("password"), "setup-pass")); form.appendChild(el("div", "auth-register", t("wizard_user_later"))); + form.appendChild(el("div", "auth-register", t("wizard_admin_hint"))); form.addEventListener("submit", ev => ev.preventDefault()); body.appendChild(form); // Felder in wizard-Zustand schreiben; Finish live aktivieren @@ -2200,6 +2214,10 @@ add.addEventListener("click", async () => { localStorage.setItem("tp_token", r.token); toast(t("user_created", { u: r.username }), "ok"); await afterAuth(); + // Einstellungen frisch rendern, falls gerade geoeffnet + if ($("#view-settings").classList.contains("is-active")) { + renderSettingsView(); + } } catch (e) { if (e.status === 403) { toast(eMsg(e), "error"); diff --git a/server.py b/server.py index db1f2c9..fa8c9b9 100644 --- a/server.py +++ b/server.py @@ -651,6 +651,8 @@ def create_user(username, password): # - Sessions wandern zum Owner (Alt-Daten gehoeren ihm), # - Settings (Sprache/Akzent aus dem Wizard) werden KOPIERT, damit der # Server-Standard (user_id=0) fuer weitere Benutzer erhalten bleibt. + # - Der Admin ist automatisch "onboarded" (Sprache/Darstellung kommen aus + # dem Server-Standard, kein erneuter Wizard). if uid == 1: con.execute("UPDATE sessions SET user_id=? WHERE user_id=?", (uid, OPEN_USER)) @@ -658,6 +660,9 @@ def create_user(username, password): "INSERT INTO settings(user_id,key,value) " "SELECT ?, key, value FROM settings WHERE user_id=?", (uid, OPEN_USER)) + con.execute( + "INSERT INTO settings(user_id,key,value) VALUES(?, 'onboarded', 'true')", + (uid,)) token = _issue_token(con, uid) con.commit() con.close() diff --git a/styles.css b/styles.css index dde40dd..54eb617 100644 --- a/styles.css +++ b/styles.css @@ -251,15 +251,16 @@ h3 { font-size: 15px; font-weight: 700; } display: inline-flex; align-items: center; justify-content: center; - width: 30px; - height: 30px; + width: 28px; + height: 28px; border: 2px solid var(--border); background: transparent; color: var(--muted); font: inherit; - font-size: 15px; + font-size: 14px; line-height: 1; cursor: pointer; + flex-shrink: 0; } .icon-btn:hover { border-color: var(--border-hover); color: var(--text); background: var(--bg-hover); } @@ -439,12 +440,13 @@ h3 { font-size: 15px; font-weight: 700; } .set-row { display: flex; flex-wrap: nowrap; - gap: 8px; + width: 100%; + gap: 6px; align-items: center; - padding: 6px 10px; + padding: 6px 8px; } -.set-rows { overflow-x: auto; } +.set-rows { overflow: hidden; } .set-row.is-done { background: var(--bg-subtle); } @@ -454,16 +456,17 @@ h3 { font-size: 15px; font-weight: 700; } appearance: none; display: inline-flex; align-items: center; - gap: 7px; - min-height: 34px; - padding: 0 12px; + gap: 4px; + min-height: 30px; + padding: 0 8px; border: 2px solid var(--border); background: transparent; color: var(--text); font: inherit; font-weight: 600; - font-size: 13px; + font-size: 12px; cursor: pointer; + flex-shrink: 0; } .set-check:hover { border-color: var(--border-hover); background: var(--bg-hover); } @@ -477,10 +480,10 @@ h3 { font-size: 15px; font-weight: 700; } display: inline-flex; align-items: center; justify-content: center; - width: 14px; - height: 14px; + width: 12px; + height: 12px; border: 2px solid currentColor; - font-size: 11px; + font-size: 10px; line-height: 1; } @@ -502,6 +505,8 @@ h3 { font-size: 15px; font-weight: 700; } .field-box { display: inline-flex; align-items: stretch; + flex: 1 1 0; + min-width: 0; border: 2px solid var(--border); background: var(--bg-input); cursor: text; @@ -511,15 +516,15 @@ h3 { font-size: 15px; font-weight: 700; } .field-box:hover { border-color: var(--border-hover); } .field-input { - width: 74px; - min-width: 60px; - height: 32px; - padding: 6px 4px 6px 8px; + flex: 1 1 0; + min-width: 0; + height: 30px; + padding: 0 6px; border: 0; background: transparent; color: var(--text); font-family: var(--font-data); - font-size: 15px; + font-size: 14px; font-variant-numeric: tabular-nums; outline: 0; } @@ -529,12 +534,13 @@ h3 { font-size: 15px; font-weight: 700; } .field-unit { display: inline-flex; align-items: center; - padding: 0 8px; + padding: 0 4px; border-left: 1px solid var(--border); color: var(--muted); font-family: var(--font-data); - font-size: 12px; + font-size: 11px; white-space: nowrap; + flex-shrink: 0; } /* Cardio-Felder */ diff --git a/tests/test_ui.js b/tests/test_ui.js index 6e6ca60..7b185fb 100644 --- a/tests/test_ui.js +++ b/tests/test_ui.js @@ -122,7 +122,7 @@ async function main() { const firstBlock = () => gymCard().querySelector(".ex-block"); const chk1 = () => firstBlock().querySelectorAll(".set-check")[0]; check("Set-Button mit Label 'Set' + Box", - chk1().querySelector(".set-check-label").textContent === "Set" + chk1().querySelector(".set-check-label").textContent === "S" && !!chk1().querySelector(".set-check-box")); check("Set-Button: Box leer (nicht abgehakt)", chk1().querySelector(".set-check-box").textContent === ""); firstBlock().querySelectorAll(".set-check")[2].click(); diff --git a/tests/test_ui_auth.js b/tests/test_ui_auth.js index f18f7bb..914876d 100644 --- a/tests/test_ui_auth.js +++ b/tests/test_ui_auth.js @@ -126,6 +126,8 @@ check("Setup: Sprache kommt zuerst", const reg = await api("POST", "/api/register", { username: "alex", password: "geheim" }); check("API: Register", reg.status === 200 && !!reg.body.token, reg.body); + // Admin ist automatisch eingestellt (kein Onboarding), Sprache fuer Tests auf Deutsch + await api("PUT", "/api/settings", { language: "de" }, reg.body.token); // --- Szenario 1: ohne Token -> Auth-Gate, Inline-Fehler, Login in-place --- const winNoToken = await loadApp(html, appJs, null); @@ -137,23 +139,23 @@ check("Setup: Sprache kommt zuerst", !!gate().querySelector('input#auth-user') && !!gate().querySelector('input#auth-pass') && !!gate().querySelector(".btn-primary")); - check("ohne Token: Login-Screen in Server-Sprache (en)", + check("ohne Token: Login-Screen in Server-Sprache", Array.from(gate().querySelectorAll(".btn")) - .some(b => b.textContent === "Log in") - && gate().querySelector(".auth-sub").textContent.includes("Log in to see")); + .some(b => b.textContent === "Anmelden") + && gate().querySelector(".auth-sub").textContent.includes("Anmelden, um deine Trainingsdaten zu sehen")); // falsches Kennwort -> Inline-Fehler gate().querySelector('input#auth-user').value = "alex"; gate().querySelector('input#auth-pass').value = "falsch"; - Array.from(gate().querySelectorAll(".btn")).find(b => b.textContent === "Log in").click(); + Array.from(gate().querySelectorAll(".btn")).find(b => b.textContent === "Anmelden").click(); await sleep(500); const authErr = gate().querySelector(".auth-error"); check("ohne Token: Inline-Fehler bei falschem Kennwort", authErr && !authErr.hidden - && authErr.textContent.includes("Incorrect username or password"), + && authErr.textContent.includes("Kennwort inkorrekt"), authErr && authErr.textContent); // korrektes Kennwort -> Login wechselt OHNE Reload in-place gate().querySelector('input#auth-pass').value = "geheim"; - Array.from(gate().querySelectorAll(".btn")).find(b => b.textContent === "Log in").click(); + Array.from(gate().querySelectorAll(".btn")).find(b => b.textContent === "Anmelden").click(); let loggedIn = false; for (let i = 0; i < 60; i++) { const og = winNoToken.document.querySelector("#setup-gate"); @@ -169,21 +171,8 @@ check("Setup: Sprache kommt zuerst", await sleep(100); } check("Login: wechselt ohne Reload in den eingeloggten Zustand", loggedIn); - // Onboarding abschliessen (im selben Fenster) - const ogate = () => winNoToken.document.querySelector("#setup-gate"); - if (ogate() && !ogate().hidden) { - Array.from(ogate().querySelectorAll(".day-btn")) - .find(b => b.textContent === "German").click(); - await sleep(150); - Array.from(ogate().querySelectorAll(".wizard-nav .btn")) - .find(b => b.textContent === "Weiter").click(); - await sleep(200); - Array.from(ogate().querySelectorAll(".wizard-nav .btn")) - .find(b => b.textContent === "Einrichtung abschließen").click(); - await sleep(400); - } - check("Login: Onboarding abgeschlossen, Heute-Ansicht sichtbar", - ogate() && ogate().hidden + check("Login: Admin ohne Onboarding direkt in Heute-Ansicht", + winNoToken.document.querySelector("#setup-gate").hidden && winNoToken.document.querySelectorAll(".act-card").length === 3); check("Login: kein Auth-Gate mehr", winNoToken.document.querySelector("#auth-gate").hidden); @@ -242,6 +231,23 @@ check("Setup: Sprache kommt zuerst", Array.from(rows()[0].querySelectorAll(".btn")) .some(b => b.textContent === "Kennwort ändern")); + // Berta (normaler User) durchlaeuft einmaliges Onboarding (Sprache zuerst) + const loginB = await api("POST", "/api/login", + { username: "berta", password: "geheim" }); + const winB = await loadApp(html, appJs, loginB.body.token); + await sleep(1200); + const bg = () => winB.document.querySelector("#setup-gate"); + check("Berta: Onboarding erscheint (einmalig)", + bg() && !bg().hidden); + Array.from(bg().querySelectorAll(".wizard-nav .btn")) + .find(b => b.textContent === "Weiter").click(); + await sleep(150); + Array.from(bg().querySelectorAll(".wizard-nav .btn")) + .find(b => b.textContent === "Einrichtung abschließen").click(); + await sleep(400); + check("Berta: nach Onboarding Heute-Ansicht", + bg().hidden && winB.document.querySelectorAll(".act-card").length === 3); + // --- Szenario 4: API-Login-Faelle -------------------------------------- check("API: Login mit falschem Passwort -> 401", (await api("POST", "/api/login", { username: "alex", password: "falsch" })).status === 401);