This commit is contained in:
2026-09-01 15:10:52 +02:00
parent e97ffe1701
commit c8b9591441
2 changed files with 858 additions and 3 deletions
+8 -3
View File
@@ -10,7 +10,7 @@ from copy import deepcopy
from pathlib import Path
from typing import Any, Callable
from flask import Flask, jsonify, request, send_file
from flask import Flask, Response, jsonify, request, send_file
from schema_contract import default_config, normalize_training_config, stable_id, validate_training_config
from storage import atomic_json_write, utc_now
@@ -21,6 +21,7 @@ PLANS_DIR = DATA_DIR / "plans"
ACTIVE_FILE = DATA_DIR / "active.txt"
LEGACY_CONFIG = DATA_DIR / "config.json"
BACKUPS_DIR = DATA_DIR / "backups"
STATIC_DIR = BASE_DIR / "static"
PLANS_DIR.mkdir(parents=True, exist_ok=True)
BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
@@ -160,6 +161,10 @@ def public_wrapper(wrapper: dict[str, Any], *, draft: bool = True) -> dict[str,
def _save_draft(pid: str, wrapper: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
meta = config.get("meta") if isinstance(config.get("meta"), dict) else {}
title = str(meta.get("title") or "").strip()
if title:
wrapper["name"] = title
wrapper["draft"] = normalize_training_config(config, plan_id=wrapper["plan_id"])
wrapper["draft"]["meta"]["title"] = str(wrapper["name"])
wrapper["revision"] += 1
@@ -172,7 +177,7 @@ def _mutate_draft(pid: str, mutator: Callable[[dict[str, Any]], Any]) -> tuple[d
wrapper = read_plan(pid)
draft = deepcopy(wrapper["draft"])
result = mutator(draft)
if isinstance(result, str) and result in {"day", "exercise", "item", "used"}:
if result is None or result is False or (isinstance(result, str) and result in {"day", "exercise", "item", "used"}):
return wrapper, result
saved = _save_draft(pid, wrapper, draft)
return saved, result
@@ -201,7 +206,7 @@ ensure_seed()
@app.route("/")
def index():
return jsonify({"name": "Trainingsplan-Backend", "status": "ok"})
return Response((STATIC_DIR / "index.html").read_text(encoding="utf-8"), mimetype="text/html")
@app.route("/api/plans", methods=["GET"])
+850
View File
@@ -0,0 +1,850 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Trainingsplan-Editor</title>
<style>
:root {
--bg: #f5f7fa;
--surface: #ffffff;
--line: #d8dee8;
--line-strong: #aeb8c8;
--ink: #17202e;
--muted: #657184;
--accent: #0f766e;
--accent-ink: #ffffff;
--warn: #a16207;
--danger: #b42318;
--focus: #2563eb;
--shadow: 0 1px 2px rgba(15, 23, 42, .07);
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 14px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button, input, textarea, select {
font: inherit;
color: inherit;
}
button {
border: 1px solid var(--line-strong);
background: var(--surface);
min-height: 34px;
padding: 6px 10px;
border-radius: 6px;
cursor: pointer;
}
button:hover { border-color: var(--focus); }
button.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-ink);
}
button.danger {
color: var(--danger);
border-color: #e4aaa4;
}
button.icon {
width: 34px;
min-width: 34px;
padding: 0;
}
input, textarea, select {
width: 100%;
border: 1px solid var(--line);
background: #fff;
border-radius: 6px;
padding: 8px 9px;
min-height: 34px;
}
textarea {
min-height: 74px;
resize: vertical;
}
label {
display: grid;
gap: 5px;
min-width: 0;
font-size: 12px;
color: var(--muted);
}
label > span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
border-bottom: 1px solid var(--line);
padding: 8px;
vertical-align: top;
text-align: left;
}
th {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.topbar {
position: sticky;
top: 0;
z-index: 20;
display: grid;
grid-template-columns: minmax(220px, 1fr) auto;
gap: 12px;
align-items: center;
padding: 12px 18px;
background: #17202e;
color: #fff;
box-shadow: var(--shadow);
}
.brand {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.mark {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 8px;
background: #0f766e;
font-weight: 800;
}
.title {
min-width: 0;
}
.title h1 {
margin: 0;
font-size: 18px;
line-height: 1.2;
letter-spacing: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.title .sub {
color: #b8c2d2;
font-size: 12px;
margin-top: 2px;
}
.actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.actions button,
.actions select {
width: auto;
}
.layout {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
min-height: calc(100vh - 61px);
}
.sidebar {
border-right: 1px solid var(--line);
background: #eef2f7;
padding: 14px;
}
.main {
padding: 16px;
min-width: 0;
}
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
margin-bottom: 14px;
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
}
.panel-head h2,
.panel-head h3 {
margin: 0;
font-size: 15px;
letter-spacing: 0;
}
.panel-body {
padding: 14px;
}
.grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 10px;
}
.span-2 { grid-column: span 2; }
.span-3 { grid-column: span 3; }
.span-4 { grid-column: span 4; }
.span-5 { grid-column: span 5; }
.span-6 { grid-column: span 6; }
.span-8 { grid-column: span 8; }
.span-12 { grid-column: span 12; }
.tabs {
display: grid;
gap: 6px;
margin-bottom: 14px;
}
.tab {
display: flex;
justify-content: space-between;
align-items: center;
border: 1px solid transparent;
background: transparent;
text-align: left;
width: 100%;
min-height: 38px;
}
.tab.active {
background: #fff;
border-color: var(--line);
box-shadow: var(--shadow);
}
.count {
color: var(--muted);
font-size: 12px;
}
.muted { color: var(--muted); }
.tiny { font-size: 12px; }
.status {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
}
.pill {
border: 1px solid var(--line);
border-radius: 999px;
padding: 3px 8px;
background: #fff;
color: var(--muted);
font-size: 12px;
}
.pill.warn {
border-color: #f1d395;
color: var(--warn);
background: #fff8e7;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.day-list {
display: grid;
gap: 12px;
}
.day-shell {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.day-head {
display: grid;
grid-template-columns: 64px minmax(160px, 1fr) auto;
gap: 10px;
align-items: end;
padding: 12px;
border-bottom: 1px solid var(--line);
}
.day-body {
padding: 12px;
display: grid;
gap: 12px;
}
.exercise-row {
display: grid;
grid-template-columns: minmax(180px, 2fr) 78px minmax(90px, 1fr) 90px minmax(120px, 1fr) 84px;
gap: 8px;
align-items: end;
padding: 10px 0;
border-bottom: 1px solid var(--line);
}
.exercise-row:last-child { border-bottom: 0; }
.empty {
border: 1px dashed var(--line-strong);
border-radius: 8px;
padding: 18px;
text-align: center;
color: var(--muted);
background: #fbfcfe;
}
.toast {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 50;
max-width: min(420px, calc(100vw - 32px));
border: 1px solid var(--line);
background: #17202e;
color: #fff;
border-radius: 8px;
padding: 10px 12px;
box-shadow: 0 12px 30px rgba(15, 23, 42, .22);
display: none;
}
.toast.show { display: block; }
.toast.error { background: #7a271a; }
@media (max-width: 940px) {
.topbar,
.layout {
grid-template-columns: 1fr;
}
.sidebar {
border-right: 0;
border-bottom: 1px solid var(--line);
}
.tabs {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.exercise-row,
.day-head {
grid-template-columns: 1fr;
}
.actions {
justify-content: flex-start;
}
}
@media (max-width: 700px) {
.grid { grid-template-columns: 1fr; }
.span-2, .span-3, .span-4, .span-5, .span-6, .span-8, .span-12 { grid-column: auto; }
.tabs { grid-template-columns: 1fr; }
.main, .sidebar { padding: 10px; }
}
</style>
</head>
<body>
<header class="topbar">
<div class="brand">
<div class="mark">TP</div>
<div class="title">
<h1 id="pageTitle">Trainingsplan-Editor</h1>
<div class="sub" id="pageSub">Lade...</div>
</div>
</div>
<div class="actions">
<select id="planSelect" aria-label="Plan"></select>
<button id="btnNew" type="button">Neu</button>
<button id="btnRename" type="button">Umbenennen</button>
<button id="btnDelete" type="button" class="danger">Löschen</button>
<button id="btnSave" type="button">Speichern</button>
<button id="btnPublish" type="button" class="primary">Veröffentlichen</button>
</div>
</header>
<div class="layout">
<aside class="sidebar">
<div class="tabs">
<button class="tab active" type="button" data-tab="days"><span>Tage</span><span class="count" id="dayCount">0</span></button>
<button class="tab" type="button" data-tab="exercises"><span>Übungen</span><span class="count" id="exerciseCount">0</span></button>
<button class="tab" type="button" data-tab="json"><span>JSON</span><span class="count">Draft</span></button>
</div>
<section class="panel">
<div class="panel-head"><h2>Plan</h2></div>
<div class="panel-body">
<div class="grid">
<label class="span-12"><span>Titel</span><input id="metaTitle"></label>
<label class="span-12"><span>Untertitel</span><input id="metaSubtitle"></label>
</div>
<div class="status" id="statusPills"></div>
</div>
</section>
</aside>
<main class="main">
<section id="tabDays"></section>
<section id="tabExercises" hidden></section>
<section id="tabJson" hidden></section>
</main>
</div>
<div class="toast" id="toast"></div>
<script>
const state = {
plans: [],
activePlanId: "",
wrapper: null,
cfg: null,
tab: "days",
dirty: false,
};
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
const api = (path) => window.BT && BT.url ? BT.url(path) : path;
const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (ch) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
}[ch]));
const rid = (prefix) => `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
function toast(message, error = false) {
const box = $("#toast");
box.textContent = message;
box.className = `toast show${error ? " error" : ""}`;
clearTimeout(window.__toastTimer);
window.__toastTimer = setTimeout(() => box.className = "toast", 2600);
}
async function request(path, options = {}) {
const res = await fetch(api(path), options);
const type = res.headers.get("content-type") || "";
const body = type.includes("application/json") ? await res.json() : await res.text();
if (!res.ok) {
throw new Error((body && body.error) || `HTTP ${res.status}`);
}
return body;
}
function markDirty() {
state.dirty = true;
renderStatus();
}
function exerciseName(id) {
return (state.cfg.exercises || []).find((item) => item.id === id)?.name || id || "Übung";
}
function textValue(obj, key) {
return (obj?.[key]?.items || []).join("\n");
}
function setTextItems(obj, key, value) {
obj[key] = {items: String(value || "").split("\n").map((item) => item.trim()).filter(Boolean)};
markDirty();
}
async function loadPlans(selectId = "") {
const data = await request("/api/plans");
state.plans = data.plans || [];
state.activePlanId = selectId || data.active || state.plans[0]?.id || "";
renderPlanSelect();
if (state.activePlanId) await loadPlan(state.activePlanId);
else {
state.wrapper = null;
state.cfg = null;
renderAll();
}
}
async function loadPlan(pid) {
state.wrapper = await request(`/api/plans/${encodeURIComponent(pid)}`);
state.activePlanId = pid;
state.cfg = state.wrapper.config || {meta: {}, exercises: [], days: []};
state.dirty = false;
renderAll();
}
function renderPlanSelect() {
$("#planSelect").innerHTML = state.plans.map((plan) =>
`<option value="${esc(plan.id)}"${plan.id === state.activePlanId ? " selected" : ""}>${esc(plan.name)}</option>`
).join("");
}
function renderStatus() {
const wrapper = state.wrapper || {};
const cfg = state.cfg || {days: [], exercises: []};
$("#pageTitle").textContent = cfg.meta?.title || wrapper.name || "Trainingsplan-Editor";
$("#pageSub").textContent = state.activePlanId ? `${state.activePlanId} · R${wrapper.revision || 1} / P${wrapper.published_revision || 1}` : "Kein Plan";
$("#dayCount").textContent = String((cfg.days || []).length);
$("#exerciseCount").textContent = String((cfg.exercises || []).length);
$("#statusPills").innerHTML = [
`<span class="pill">Schema ${cfg.schema_version || 4}/${cfg.contract_version || 3}</span>`,
`<span class="pill">Revision ${wrapper.revision || 1}</span>`,
`<span class="pill">Veröffentlicht ${wrapper.published_revision || 1}</span>`,
state.dirty || wrapper.has_unpublished_changes ? `<span class="pill warn">Ungespeichert</span>` : `<span class="pill">Aktuell</span>`,
].join("");
}
function renderAll() {
if (!state.cfg) {
$(".main").innerHTML = `<div class="empty">Kein Plan vorhanden.</div>`;
renderStatus();
return;
}
$("#metaTitle").value = state.cfg.meta?.title || "";
$("#metaSubtitle").value = state.cfg.meta?.subtitle || "";
renderStatus();
renderTabs();
renderDays();
renderExercises();
renderJson();
}
function renderTabs() {
$$(".tab").forEach((button) => button.classList.toggle("active", button.dataset.tab === state.tab));
$("#tabDays").hidden = state.tab !== "days";
$("#tabExercises").hidden = state.tab !== "exercises";
$("#tabJson").hidden = state.tab !== "json";
}
function renderDays() {
const host = $("#tabDays");
const days = state.cfg.days || [];
const options = (state.cfg.exercises || []).map((exercise) =>
`<option value="${esc(exercise.id)}">${esc(exercise.name || exercise.id)}</option>`
).join("");
host.innerHTML = `
<section class="panel">
<div class="panel-head">
<h2>Trainingstage</h2>
<div class="toolbar"><button type="button" data-add-day>Tag hinzufügen</button></div>
</div>
</section>
<div class="day-list">
${days.length ? days.map((day, index) => renderDay(day, index, options)).join("") : `<div class="empty">Noch keine Trainingstage.</div>`}
</div>
`;
days.forEach((day) => {
(day.exercises || []).forEach((item) => {
const row = host.querySelector(`[data-day-item="${CSS.escape(item.id)}"]`);
const select = row?.querySelector('[data-day-field="exercise_id"]');
if (select) select.value = item.exercise_id || "";
});
});
}
function renderDay(day, index, options) {
const rows = (day.exercises || []).map((item, itemIndex) => `
<div class="exercise-row" data-day-item="${esc(item.id)}">
<label><span>Übung</span><select data-day-field="exercise_id">${options}</select></label>
<label><span>Sätze</span><input type="number" min="1" max="40" data-day-field="sets" value="${esc(item.sets || "")}"></label>
<label><span>Wdh.</span><input data-day-field="reps" value="${esc(item.reps || "")}"></label>
<label><span>Sek.</span><input type="number" min="1" data-day-field="duration_seconds" value="${esc(item.duration_seconds || "")}"></label>
<label><span>Notiz</span><input data-day-field="notes" value="${esc(item.notes || "")}"></label>
<div class="toolbar">
<button class="icon" type="button" title="Nach oben" data-move-day-item="-1">↑</button>
<button class="icon" type="button" title="Nach unten" data-move-day-item="1">↓</button>
<button class="icon danger" type="button" title="Entfernen" data-remove-day-item>×</button>
</div>
</div>
`).join("");
return `
<section class="day-shell" data-day-id="${esc(day.id)}">
<div class="day-head">
<label><span>Nr.</span><input type="number" min="1" data-day-prop="num" value="${esc(day.num || index + 1)}"></label>
<label><span>Name</span><input data-day-prop="name" value="${esc(day.name || day.focus || `Tag ${index + 1}`)}"></label>
<div class="toolbar">
<button class="icon" type="button" title="Tag nach oben" data-move-day="-1">↑</button>
<button class="icon" type="button" title="Tag nach unten" data-move-day="1">↓</button>
<button class="danger" type="button" data-remove-day>Tag löschen</button>
</div>
</div>
<div class="day-body">
<div class="grid">
<label class="span-6"><span>Warm-up</span><textarea data-day-text="warmup">${esc(textValue(day, "warmup"))}</textarea></label>
<label class="span-6"><span>Cool-down</span><textarea data-day-text="cooldown">${esc(textValue(day, "cooldown"))}</textarea></label>
</div>
<div>
<table>
<thead><tr><th>Übung</th><th>Sätze</th><th>Wdh.</th><th>Sek.</th><th>Notiz</th><th></th></tr></thead>
</table>
${rows || `<div class="empty">Keine Übungen an diesem Tag.</div>`}
<div class="toolbar" style="margin-top:10px">
<select data-new-day-exercise>${options}</select>
<button type="button" data-add-day-exercise>Übung hinzufügen</button>
</div>
</div>
</div>
</section>
`;
}
function renderExercises() {
const host = $("#tabExercises");
host.innerHTML = `
<section class="panel">
<div class="panel-head"><h2>Übungen</h2></div>
<div class="panel-body">
<div class="grid">
<label class="span-4"><span>Name</span><input id="newExerciseName"></label>
<label class="span-4"><span>Cue</span><input id="newExerciseCue"></label>
<label class="span-3"><span>Equipment</span><input id="newExerciseEquipment"></label>
<div class="span-1 toolbar" style="align-items:end"><button class="icon primary" type="button" title="Übung hinzufügen" data-add-exercise>+</button></div>
</div>
</div>
</section>
<section class="panel">
<div class="panel-head"><h2>Bibliothek</h2></div>
<div class="panel-body">
${(state.cfg.exercises || []).length ? `
<table>
<thead><tr><th>Name</th><th>Cue</th><th>Equipment</th><th>Beschreibung</th><th></th></tr></thead>
<tbody>
${(state.cfg.exercises || []).map((exercise) => `
<tr data-exercise-id="${esc(exercise.id)}">
<td><input data-exercise-field="name" value="${esc(exercise.name || "")}"></td>
<td><input data-exercise-field="cue" value="${esc(exercise.cue || "")}"></td>
<td><input data-exercise-field="equipment" value="${esc(exercise.equipment || "")}"></td>
<td><input data-exercise-field="description" value="${esc(exercise.description || "")}"></td>
<td><button class="icon danger" type="button" title="Übung löschen" data-remove-exercise>×</button></td>
</tr>
`).join("")}
</tbody>
</table>` : `<div class="empty">Noch keine Übungen.</div>`}
</div>
</section>
`;
}
function renderJson() {
$("#tabJson").innerHTML = `
<section class="panel">
<div class="panel-head">
<h2>JSON</h2>
<div class="toolbar"><button type="button" data-apply-json>Übernehmen</button></div>
</div>
<div class="panel-body">
<textarea id="jsonEditor" style="min-height:520px;font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">${esc(JSON.stringify(state.cfg, null, 2))}</textarea>
</div>
</section>
`;
}
function addExerciseFromForm() {
const name = $("#newExerciseName").value.trim();
if (!name) return toast("Name fehlt.", true);
state.cfg.exercises = state.cfg.exercises || [];
state.cfg.exercises.push({
id: rid("exercise"),
name,
cue: $("#newExerciseCue").value.trim(),
equipment: $("#newExerciseEquipment").value.trim(),
});
markDirty();
renderAll();
}
function addDay() {
const nums = (state.cfg.days || []).map((day) => Number(day.num || 0));
const num = Math.max(0, ...nums) + 1;
state.cfg.days = state.cfg.days || [];
state.cfg.days.push({
id: rid("day"),
num,
name: `Tag ${num}`,
warmup: {items: []},
cooldown: {items: []},
exercises: [],
});
markDirty();
renderAll();
}
function move(list, index, delta) {
const next = index + delta;
if (next < 0 || next >= list.length) return;
const [item] = list.splice(index, 1);
list.splice(next, 0, item);
markDirty();
renderAll();
}
async function savePlan() {
if (!state.activePlanId || !state.cfg) return;
const body = {expected_revision: state.wrapper.revision, config: state.cfg};
const saved = await request(`/api/plans/${encodeURIComponent(state.activePlanId)}`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(body),
});
state.wrapper = saved;
state.cfg = saved.config;
state.dirty = false;
renderAll();
const errors = saved.validation?.errors?.length || 0;
toast(errors ? `${errors} Fehler beim Speichern.` : "Gespeichert.", Boolean(errors));
}
async function publishPlan() {
if (state.dirty) await savePlan();
const saved = await request(`/api/plans/${encodeURIComponent(state.activePlanId)}/publish`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({expected_revision: state.wrapper.revision}),
});
state.wrapper = saved;
state.cfg = saved.config;
state.dirty = false;
renderAll();
await loadPlans(state.activePlanId);
toast("Veröffentlicht.");
}
document.addEventListener("input", (event) => {
const target = event.target;
if (!state.cfg) return;
if (target.id === "metaTitle") {
state.cfg.meta = state.cfg.meta || {};
state.cfg.meta.title = target.value;
markDirty();
}
if (target.id === "metaSubtitle") {
state.cfg.meta = state.cfg.meta || {};
if (target.value.trim()) state.cfg.meta.subtitle = target.value;
else delete state.cfg.meta.subtitle;
markDirty();
}
const exerciseRow = target.closest("[data-exercise-id]");
if (exerciseRow && target.dataset.exerciseField) {
const exercise = (state.cfg.exercises || []).find((item) => item.id === exerciseRow.dataset.exerciseId);
if (exercise) {
const key = target.dataset.exerciseField;
if (target.value.trim()) exercise[key] = target.value;
else if (key !== "name") delete exercise[key];
markDirty();
}
}
const dayShell = target.closest("[data-day-id]");
if (dayShell) {
const day = (state.cfg.days || []).find((item) => item.id === dayShell.dataset.dayId);
if (!day) return;
const itemRow = target.closest("[data-day-item]");
if (target.dataset.dayProp) {
day[target.dataset.dayProp] = target.dataset.dayProp === "num" ? Number(target.value || 1) : target.value;
markDirty();
} else if (target.dataset.dayText) {
setTextItems(day, target.dataset.dayText, target.value);
} else if (itemRow && target.dataset.dayField) {
const item = (day.exercises || []).find((entry) => entry.id === itemRow.dataset.dayItem);
if (!item) return;
const key = target.dataset.dayField;
if (key === "sets" || key === "duration_seconds") {
const value = Number(target.value || 0);
if (value > 0) item[key] = value;
else delete item[key];
} else if (target.value.trim()) item[key] = target.value;
else delete item[key];
markDirty();
}
}
});
document.addEventListener("click", async (event) => {
const target = event.target.closest("button");
if (!target) return;
if (target.dataset.tab) {
state.tab = target.dataset.tab;
renderTabs();
if (state.tab === "json") renderJson();
} else if (target.id === "btnNew") {
const name = prompt("Name des neuen Plans:", "Neuer Trainingsplan");
if (!name) return;
const created = await request("/api/plans", {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({name})});
await loadPlans(created.id);
} else if (target.id === "btnRename") {
const name = prompt("Neuer Name:", state.wrapper?.name || "");
if (!name) return;
await request(`/api/plans/${encodeURIComponent(state.activePlanId)}/rename`, {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({name})});
await loadPlans(state.activePlanId);
} else if (target.id === "btnDelete") {
if (!confirm("Plan löschen?")) return;
await request(`/api/plans/${encodeURIComponent(state.activePlanId)}`, {method: "DELETE"});
await loadPlans();
} else if (target.id === "btnSave") {
await savePlan();
} else if (target.id === "btnPublish") {
await publishPlan();
} else if (target.dataset.addDay !== undefined) {
addDay();
} else if (target.dataset.addExercise !== undefined) {
addExerciseFromForm();
} else if (target.dataset.applyJson !== undefined) {
try {
state.cfg = JSON.parse($("#jsonEditor").value);
markDirty();
renderAll();
} catch (err) {
toast(err.message, true);
}
}
const dayShell = target.closest("[data-day-id]");
if (dayShell) {
const days = state.cfg.days || [];
const dayIndex = days.findIndex((day) => day.id === dayShell.dataset.dayId);
const day = days[dayIndex];
if (!day) return;
if (target.dataset.moveDay) {
move(days, dayIndex, Number(target.dataset.moveDay));
} else if (target.dataset.removeDay !== undefined) {
if (!confirm("Tag löschen?")) return;
days.splice(dayIndex, 1);
markDirty();
renderAll();
} else if (target.dataset.addDayExercise !== undefined) {
const select = dayShell.querySelector("[data-new-day-exercise]");
const exerciseId = select?.value;
if (!exerciseId) return toast("Keine Übung ausgewählt.", true);
day.exercises = day.exercises || [];
day.exercises.push({id: rid("day-exercise"), exercise_id: exerciseId});
markDirty();
renderAll();
}
const itemRow = target.closest("[data-day-item]");
if (itemRow) {
const itemIndex = (day.exercises || []).findIndex((item) => item.id === itemRow.dataset.dayItem);
if (target.dataset.moveDayItem) {
move(day.exercises, itemIndex, Number(target.dataset.moveDayItem));
} else if (target.dataset.removeDayItem !== undefined) {
day.exercises.splice(itemIndex, 1);
markDirty();
renderAll();
}
}
}
const exerciseRow = target.closest("[data-exercise-id]");
if (exerciseRow && target.dataset.removeExercise !== undefined) {
const id = exerciseRow.dataset.exerciseId;
const used = (state.cfg.days || []).some((day) => (day.exercises || []).some((item) => item.exercise_id === id));
if (used && !confirm("Übung ist in Tagen hinterlegt. Trotzdem entfernen?")) return;
state.cfg.exercises = (state.cfg.exercises || []).filter((exercise) => exercise.id !== id);
if (used) {
(state.cfg.days || []).forEach((day) => {
day.exercises = (day.exercises || []).filter((item) => item.exercise_id !== id);
});
}
markDirty();
renderAll();
}
});
$("#planSelect").addEventListener("change", async (event) => {
if (state.dirty && !confirm("Ungespeicherte Änderungen verwerfen?")) {
renderPlanSelect();
return;
}
await request(`/api/plans/${encodeURIComponent(event.target.value)}/select`, {method: "POST"});
await loadPlan(event.target.value);
});
window.addEventListener("beforeunload", (event) => {
if (!state.dirty) return;
event.preventDefault();
event.returnValue = "";
});
loadPlans().catch((err) => toast(err.message, true));
</script>
</body>
</html>