723 lines
29 KiB
HTML
723 lines
29 KiB
HTML
<!doctype html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Trainingsplan-Editor</title>
|
||
<link rel="stylesheet" href="/shared/boehmi.css">
|
||
<script src="/shared/boehmi.js"></script>
|
||
<style>
|
||
.tp-top { display: grid; grid-template-columns: minmax(260px, 1fr) auto; gap: 12px; align-items: end; }
|
||
.tp-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
||
.tp-actions button, .tp-actions select { width: auto; }
|
||
.tp-layout { display: grid; grid-template-columns: 320px minmax(0, 1fr); gap: 14px; align-items: start; }
|
||
.tp-side { position: sticky; top: calc(var(--bt-appbar-h) + 12px); }
|
||
.tp-statline { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
|
||
.tp-day { border: 1px solid var(--bt-line); border-radius: var(--bt-r-lg); background: var(--bt-surface); margin-bottom: 14px; box-shadow: var(--bt-shadow); overflow: hidden; }
|
||
.tp-day-head { display: grid; grid-template-columns: 86px minmax(180px, 1fr) auto; gap: 10px; align-items: end; padding: 14px 16px; background: var(--bt-surface-2); border-bottom: 1px solid var(--bt-line); }
|
||
.tp-day-body { padding: 14px 16px 16px; }
|
||
.tp-day-actions { display: flex; gap: 6px; justify-content: flex-end; align-items: center; flex-wrap: wrap; }
|
||
.tp-textareas { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 14px; }
|
||
.tp-exercise-row { display: grid; grid-template-columns: minmax(220px, 1.2fr) minmax(180px, 1fr) auto; gap: 8px; align-items: end; padding: 9px 0; border-bottom: 1px solid var(--bt-line-soft); }
|
||
.tp-exercise-row:last-child { border-bottom: none; }
|
||
.tp-exercise-actions { display: flex; gap: 5px; justify-content: flex-end; }
|
||
.tp-field { margin: 0; }
|
||
.tp-compact-input, .tp-library input, .tp-library select {
|
||
min-height: 34px;
|
||
padding: 6px 9px;
|
||
border-radius: var(--bt-r-sm);
|
||
font-size: 12.5px;
|
||
line-height: 1.3;
|
||
}
|
||
.tp-library { display: grid; gap: 0; }
|
||
.tp-library-row { border-bottom: 1px solid var(--bt-line); padding: 0; }
|
||
.tp-library-row:last-child { border-bottom: none; }
|
||
.tp-library-main {
|
||
display: grid;
|
||
grid-template-columns: minmax(240px, 1fr) minmax(240px, 1fr) auto;
|
||
gap: 10px;
|
||
align-items: end;
|
||
padding: 12px 14px;
|
||
background: var(--bt-surface-2);
|
||
border-bottom: 1px solid var(--bt-line-soft);
|
||
}
|
||
.tp-library-id { padding: 7px 14px 12px; font-size: 11px; }
|
||
.tp-progressions { display: grid; gap: 0; margin: 0; }
|
||
.tp-progressions-head {
|
||
display: grid;
|
||
grid-template-columns: 34px minmax(180px, 1fr) auto;
|
||
gap: 8px;
|
||
align-items: center;
|
||
padding: 8px 14px 4px;
|
||
color: var(--bt-muted);
|
||
font-size: 11px;
|
||
font-weight: 800;
|
||
letter-spacing: .06em;
|
||
text-transform: uppercase;
|
||
}
|
||
.tp-progression-row {
|
||
display: grid;
|
||
grid-template-columns: 34px minmax(180px, 1fr) auto;
|
||
gap: 8px;
|
||
align-items: center;
|
||
padding: 6px 14px;
|
||
border-top: 1px solid var(--bt-line-soft);
|
||
}
|
||
.tp-progression-number { color: var(--bt-muted); font-family: var(--bt-mono); font-size: 11px; text-align: center; }
|
||
.tp-progression-input { margin: 0; }
|
||
.tp-addline, .tp-formline { display: grid; grid-template-columns: minmax(220px, 1fr) auto; gap: 8px; align-items: end; margin-top: 12px; }
|
||
.tp-table-input { width: 100%; min-width: 0; }
|
||
.tp-code { min-height: 520px; font-family: var(--bt-mono); font-size: 12.5px; }
|
||
.tp-warn { color: var(--bt-warn); }
|
||
@media (max-width: 980px) {
|
||
.tp-top, .tp-layout, .tp-day-head, .tp-textareas, .tp-addline, .tp-formline { grid-template-columns: 1fr; }
|
||
.tp-side { position: static; }
|
||
.tp-actions, .tp-day-actions { justify-content: flex-start; }
|
||
.tp-exercise-row, .tp-library-main, .tp-progression-row, .tp-progressions-head { grid-template-columns: 1fr; }
|
||
.tp-exercise-actions { justify-content: flex-start; }
|
||
.tp-progression-number { text-align: left; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body data-bt-title="Trainingsplan-Editor" data-bt-icon="🏋">
|
||
<main class="bt-main wide">
|
||
<div class="bt-pagehead">
|
||
<div class="tp-top">
|
||
<div>
|
||
<h1>Trainingsplan-Editor</h1>
|
||
<p id="headline">Pläne, Tage, Warm-up, Cool-down und Übungen pflegen.</p>
|
||
</div>
|
||
<div class="tp-actions">
|
||
<select id="planSelect" aria-label="Plan auswählen"></select>
|
||
<button id="btnNew" class="ghost">Neu</button>
|
||
<button id="btnRename" class="ghost">Umbenennen</button>
|
||
<button id="btnDelete" class="danger">Löschen</button>
|
||
<button id="btnSave">Speichern</button>
|
||
<button id="btnPublish" class="primary">Veröffentlichen</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="warn" class="bt-notice err bt-hidden"></div>
|
||
|
||
<div class="bt-tabs" id="tabs">
|
||
<button class="bt-tab active" data-tab="tage">Tage</button>
|
||
<button class="bt-tab" data-tab="uebungen">Übungen</button>
|
||
<button class="bt-tab" data-tab="json">JSON</button>
|
||
</div>
|
||
|
||
<div class="tp-layout" style="margin-top:14px">
|
||
<aside class="tp-side">
|
||
<div class="bt-card">
|
||
<h2>Plan</h2>
|
||
<label for="metaTitle">Titel</label>
|
||
<input id="metaTitle" type="text">
|
||
<label for="metaSubtitle">Untertitel</label>
|
||
<input id="metaSubtitle" type="text">
|
||
<div class="tp-statline" id="statusPills"></div>
|
||
</div>
|
||
</aside>
|
||
|
||
<section id="tab-tage" class="tab"></section>
|
||
<section id="tab-uebungen" class="tab bt-hidden"></section>
|
||
<section id="tab-json" class="tab bt-hidden"></section>
|
||
</div>
|
||
</main>
|
||
|
||
<script>
|
||
"use strict";
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
||
const api = async (path, options = {}) => {
|
||
if (window.BT) return BT.api(path, options);
|
||
const opts = {...options};
|
||
if (opts.body !== undefined && !(opts.body instanceof FormData)) {
|
||
opts.headers = {"Content-Type": "application/json", ...(opts.headers || {})};
|
||
}
|
||
const response = await fetch(path, opts);
|
||
const text = await response.text();
|
||
let payload = {};
|
||
try { payload = text ? JSON.parse(text) : {}; } catch (_) { payload = {raw: text}; }
|
||
if (!response.ok) throw new Error(payload.error || payload.detail || `HTTP ${response.status}`);
|
||
return payload;
|
||
};
|
||
const esc = (value) => window.BT ? BT.escape(value) : String(value ?? "").replace(/[&<>"']/g, (ch) => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}[ch]));
|
||
const uid = (prefix) => `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||
|
||
let state = {
|
||
plans: [],
|
||
activePlanId: "",
|
||
wrapper: null,
|
||
cfg: null,
|
||
tab: "tage",
|
||
dirty: false,
|
||
};
|
||
|
||
function toast(message, kind = "") {
|
||
if (window.BT) BT.toast(message, kind);
|
||
else alert(message);
|
||
}
|
||
|
||
function showError(message) {
|
||
const warn = $("warn");
|
||
warn.textContent = message;
|
||
warn.classList.remove("bt-hidden");
|
||
}
|
||
|
||
function clearError() {
|
||
$("warn").classList.add("bt-hidden");
|
||
}
|
||
|
||
function markDirty() {
|
||
state.dirty = true;
|
||
renderStatus();
|
||
}
|
||
|
||
function textItems(obj, key) {
|
||
return (obj?.[key]?.items || []).join("\n");
|
||
}
|
||
|
||
function setTextItems(obj, key, text) {
|
||
obj[key] = {items: String(text || "").split("\n").map((item) => item.trim()).filter(Boolean)};
|
||
markDirty();
|
||
}
|
||
|
||
function exerciseName(id) {
|
||
return (state.cfg?.exercises || []).find((exercise) => exercise.id === id)?.name || id || "Übung";
|
||
}
|
||
|
||
function progressionOptions(exercise, selected = "") {
|
||
return (exercise.progressions || []).map((progression) =>
|
||
`<option value="${esc(progression.id)}"${progression.id === selected ? " selected" : ""}>${esc(progression.name || progression.id)}</option>`
|
||
).join("");
|
||
}
|
||
|
||
function dayProgressionSelect(exerciseId) {
|
||
const exercise = (state.cfg?.exercises || []).find((item) => item.id === exerciseId);
|
||
const progressions = exercise?.progressions || [];
|
||
if (!progressions.length) {
|
||
return `<select data-day-current-progression disabled><option value="">Keine Progression</option></select>`;
|
||
}
|
||
const selected = exercise.current_progression_id || progressions[0].id;
|
||
return `<select data-day-current-progression>${progressionOptions(exercise, selected)}</select>`;
|
||
}
|
||
|
||
function optionList(selected = "") {
|
||
return (state.cfg?.exercises || []).map((exercise) =>
|
||
`<option value="${esc(exercise.id)}"${exercise.id === selected ? " selected" : ""}>${esc(exercise.name || exercise.id)}</option>`
|
||
).join("");
|
||
}
|
||
|
||
async function loadPlans(selectId = "") {
|
||
clearError();
|
||
const data = await api("/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) {
|
||
clearError();
|
||
state.wrapper = await api(`/api/plans/${encodeURIComponent(pid)}`);
|
||
state.activePlanId = pid;
|
||
state.cfg = state.wrapper.config || {meta: {}, exercises: [], days: []};
|
||
state.cfg.exercises = state.cfg.exercises || [];
|
||
state.cfg.days = state.cfg.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 cfg = state.cfg || {days: [], exercises: []};
|
||
const wrapper = state.wrapper || {};
|
||
$("headline").textContent = state.activePlanId
|
||
? `${state.activePlanId} · Bearbeitung R${wrapper.revision || 1} · veröffentlicht R${wrapper.published_revision || 1}`
|
||
: "Kein Trainingsplan gefunden.";
|
||
$("statusPills").innerHTML = [
|
||
`<span class="bt-badge">Schema ${cfg.schema_version || 5}/${cfg.contract_version || 4}</span>`,
|
||
`<span class="bt-badge">${(cfg.days || []).length} Tage</span>`,
|
||
`<span class="bt-badge">${(cfg.exercises || []).length} Übungen</span>`,
|
||
state.dirty || wrapper.has_unpublished_changes
|
||
? `<span class="bt-badge warn">Ungespeichert</span>`
|
||
: `<span class="bt-badge ok">Aktuell</span>`,
|
||
].join("");
|
||
}
|
||
|
||
function renderAll() {
|
||
if (!state.cfg) {
|
||
$("metaTitle").value = "";
|
||
$("metaSubtitle").value = "";
|
||
$("tab-tage").innerHTML = `<div class="bt-empty">Kein Plan vorhanden.</div>`;
|
||
$("tab-uebungen").innerHTML = "";
|
||
$("tab-json").innerHTML = "";
|
||
renderStatus();
|
||
return;
|
||
}
|
||
$("metaTitle").value = state.cfg.meta?.title || "";
|
||
$("metaSubtitle").value = state.cfg.meta?.subtitle || "";
|
||
renderTabs();
|
||
renderStatus();
|
||
renderDays();
|
||
renderExercises();
|
||
renderJson();
|
||
}
|
||
|
||
function renderTabs() {
|
||
$$("#tabs button").forEach((button) => button.classList.toggle("active", button.dataset.tab === state.tab));
|
||
$$(".tp-layout > .tab").forEach((section) => section.classList.add("bt-hidden"));
|
||
$(`tab-${state.tab}`).classList.remove("bt-hidden");
|
||
}
|
||
|
||
function renderDays() {
|
||
const days = state.cfg.days || [];
|
||
$("tab-tage").innerHTML = `
|
||
<div class="bt-card">
|
||
<div class="bt-row" style="align-items:center">
|
||
<div style="flex:1">
|
||
<h2 style="margin:0">Trainingstage</h2>
|
||
<p class="bt-hint" style="margin:4px 0 0">Warm-up und Cool-down bleiben am Tag, Übungen werden aus der Bibliothek hinterlegt.</p>
|
||
</div>
|
||
<button class="primary" data-add-day>Tag hinzufügen</button>
|
||
</div>
|
||
</div>
|
||
${days.length ? days.map((day, index) => dayHtml(day, index)).join("") : `<div class="bt-empty">Noch keine Trainingstage.</div>`}
|
||
`;
|
||
}
|
||
|
||
function dayHtml(day, index) {
|
||
const items = (day.exercises || []).map((item, itemIndex) => `
|
||
<div class="tp-exercise-row" data-day-item="${esc(item.id)}">
|
||
<label>Übung<select data-day-field="exercise_id">${optionList(item.exercise_id)}</select></label>
|
||
<label>Aktuell${dayProgressionSelect(item.exercise_id)}</label>
|
||
<div class="tp-exercise-actions">
|
||
<button class="mini ghost" data-move-day-item="-1" title="Nach oben">↑</button>
|
||
<button class="mini ghost" data-move-day-item="1" title="Nach unten">↓</button>
|
||
<button class="mini danger" data-remove-day-item title="Entfernen">×</button>
|
||
</div>
|
||
</div>
|
||
`).join("");
|
||
return `
|
||
<article class="tp-day" data-day-id="${esc(day.id)}">
|
||
<div class="tp-day-head">
|
||
<label>Nr.<input type="number" min="1" data-day-prop="num" value="${esc(day.num || index + 1)}"></label>
|
||
<label>Name<input type="text" data-day-prop="name" value="${esc(day.name || day.focus || `Tag ${index + 1}`)}"></label>
|
||
<div class="tp-day-actions">
|
||
<button class="mini ghost" data-move-day="-1">↑</button>
|
||
<button class="mini ghost" data-move-day="1">↓</button>
|
||
<button class="mini danger" data-remove-day>Tag löschen</button>
|
||
</div>
|
||
</div>
|
||
<div class="tp-day-body">
|
||
<div class="tp-textareas">
|
||
<label>Warm-up<textarea data-day-text="warmup">${esc(textItems(day, "warmup"))}</textarea></label>
|
||
<label>Cool-down<textarea data-day-text="cooldown">${esc(textItems(day, "cooldown"))}</textarea></label>
|
||
</div>
|
||
<h3>Übungen</h3>
|
||
${items || `<div class="bt-empty">Keine Übungen an diesem Tag.</div>`}
|
||
<div class="tp-addline">
|
||
<label>Übung hinzufügen<select data-new-day-exercise>${optionList()}</select></label>
|
||
<button data-add-day-exercise>Hinzufügen</button>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
function renderExercises() {
|
||
const rows = (state.cfg.exercises || []).map((exercise) => `
|
||
<div class="tp-library-row" data-exercise-id="${esc(exercise.id)}">
|
||
<div class="tp-library-main">
|
||
<label class="tp-field">Name<input class="tp-table-input tp-compact-input" data-exercise-field="name" value="${esc(exercise.name || "")}"></label>
|
||
<label class="tp-field">Aktuelle Progression<select class="tp-compact-input" data-exercise-field="current_progression_id">
|
||
<option value="">Keine Progression</option>
|
||
${progressionOptions(exercise, exercise.current_progression_id)}
|
||
</select></label>
|
||
<div class="tp-exercise-actions">
|
||
<button class="mini ghost" data-add-progression>Progression</button>
|
||
<button class="mini danger" data-remove-exercise>×</button>
|
||
</div>
|
||
</div>
|
||
<div class="tp-progressions">
|
||
${(exercise.progressions || []).length ? `
|
||
<div class="tp-progressions-head">
|
||
<span></span>
|
||
<span>Progressionen</span>
|
||
<span></span>
|
||
</div>
|
||
` : ""}
|
||
${(exercise.progressions || []).map((progression, index) => `
|
||
<div class="tp-progression-row" data-progression-id="${esc(progression.id)}">
|
||
<div class="tp-progression-number">${index + 1}</div>
|
||
<input class="tp-table-input tp-compact-input tp-progression-input" aria-label="Progression ${index + 1}" data-progression-field="name" value="${esc(progression.name || "")}">
|
||
<div class="tp-exercise-actions">
|
||
<button class="mini ghost" data-move-progression="-1" title="Nach oben">↑</button>
|
||
<button class="mini ghost" data-move-progression="1" title="Nach unten">↓</button>
|
||
<button class="mini danger" data-remove-progression title="Entfernen">×</button>
|
||
</div>
|
||
</div>
|
||
`).join("") || `<div class="bt-empty">Keine Progressionen hinterlegt.</div>`}
|
||
</div>
|
||
<div class="tp-library-id bt-mono bt-muted">${esc(exercise.id)}</div>
|
||
</div>
|
||
`).join("");
|
||
$("tab-uebungen").innerHTML = `
|
||
<div class="bt-card">
|
||
<h2>Übung anlegen</h2>
|
||
<div class="tp-formline">
|
||
<label>Name<input id="newExerciseName" class="tp-compact-input" type="text"></label>
|
||
<button class="primary" data-add-exercise>Hinzufügen</button>
|
||
</div>
|
||
</div>
|
||
<div class="bt-card bt-card-flush">
|
||
${rows ? `<div class="tp-library">${rows}</div>` : `<div class="bt-empty" style="margin:16px">Noch keine Übungen.</div>`}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderJson() {
|
||
$("tab-json").innerHTML = `
|
||
<div class="bt-card">
|
||
<div class="bt-row" style="align-items:center">
|
||
<div style="flex:1">
|
||
<h2 style="margin:0">JSON</h2>
|
||
<p class="bt-hint" style="margin:4px 0 0">Direktbearbeitung des aktuellen Entwurfs.</p>
|
||
</div>
|
||
<button data-apply-json>Übernehmen</button>
|
||
</div>
|
||
<textarea id="jsonEditor" class="tp-code">${esc(JSON.stringify(state.cfg, null, 2))}</textarea>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function addExerciseFromForm() {
|
||
const name = $("newExerciseName")?.value.trim();
|
||
if (!name) return toast("Name fehlt.", "err");
|
||
state.cfg.exercises.push({
|
||
id: uid("exercise"),
|
||
name,
|
||
progressions: [],
|
||
current_progression_id: "",
|
||
});
|
||
markDirty();
|
||
renderAll();
|
||
}
|
||
|
||
function addDay() {
|
||
const next = Math.max(0, ...(state.cfg.days || []).map((day) => Number(day.num || 0))) + 1;
|
||
state.cfg.days.push({id: uid("day"), num: next, name: `Tag ${next}`, warmup: {items: []}, cooldown: {items: []}, exercises: []});
|
||
markDirty();
|
||
renderAll();
|
||
}
|
||
|
||
function move(list, index, delta) {
|
||
const next = index + delta;
|
||
if (!list || index < 0 || next < 0 || next >= list.length) return;
|
||
const [item] = list.splice(index, 1);
|
||
list.splice(next, 0, item);
|
||
markDirty();
|
||
renderAll();
|
||
}
|
||
|
||
function syncFormStateFromDom() {
|
||
if (!state.cfg) return;
|
||
state.cfg.meta = state.cfg.meta || {};
|
||
state.cfg.meta.title = $("metaTitle").value;
|
||
if ($("metaSubtitle").value.trim()) state.cfg.meta.subtitle = $("metaSubtitle").value;
|
||
else delete state.cfg.meta.subtitle;
|
||
|
||
document.querySelectorAll("[data-exercise-id]").forEach((row) => {
|
||
const exercise = state.cfg.exercises.find((item) => item.id === row.dataset.exerciseId);
|
||
if (!exercise) return;
|
||
row.querySelectorAll("[data-exercise-field]").forEach((input) => {
|
||
const key = input.dataset.exerciseField;
|
||
if (key === "name") exercise.name = input.value;
|
||
else if (input.value.trim()) exercise[key] = input.value;
|
||
else delete exercise[key];
|
||
});
|
||
row.querySelectorAll("[data-progression-id]").forEach((progressionRow) => {
|
||
const progression = (exercise.progressions || []).find((item) => item.id === progressionRow.dataset.progressionId);
|
||
const input = progressionRow.querySelector('[data-progression-field="name"]');
|
||
if (progression && input) progression.name = input.value;
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll("[data-day-id]").forEach((shell) => {
|
||
const day = state.cfg.days.find((item) => item.id === shell.dataset.dayId);
|
||
if (!day) return;
|
||
const num = shell.querySelector('[data-day-prop="num"]');
|
||
const name = shell.querySelector('[data-day-prop="name"]');
|
||
const warmup = shell.querySelector('[data-day-text="warmup"]');
|
||
const cooldown = shell.querySelector('[data-day-text="cooldown"]');
|
||
if (num) day.num = Number(num.value || 1);
|
||
if (name) {
|
||
day.name = name.value;
|
||
delete day.focus;
|
||
}
|
||
if (warmup) day.warmup = {items: warmup.value.split("\n").map((item) => item.trim()).filter(Boolean)};
|
||
if (cooldown) day.cooldown = {items: cooldown.value.split("\n").map((item) => item.trim()).filter(Boolean)};
|
||
|
||
shell.querySelectorAll("[data-day-item]").forEach((row) => {
|
||
const item = (day.exercises || []).find((entry) => entry.id === row.dataset.dayItem);
|
||
if (!item) return;
|
||
const exercise = row.querySelector('[data-day-field="exercise_id"]');
|
||
if (exercise) item.exercise_id = exercise.value;
|
||
const current = row.querySelector("[data-day-current-progression]");
|
||
const selectedExercise = state.cfg.exercises.find((entry) => entry.id === item.exercise_id);
|
||
if (selectedExercise && current && current.value) {
|
||
selectedExercise.current_progression_id = current.value;
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
async function savePlan() {
|
||
if (!state.activePlanId || !state.cfg) return;
|
||
syncFormStateFromDom();
|
||
const saved = await api(`/api/plans/${encodeURIComponent(state.activePlanId)}`, {
|
||
method: "POST",
|
||
body: JSON.stringify({expected_revision: state.wrapper.revision, config: state.cfg}),
|
||
});
|
||
state.wrapper = saved;
|
||
state.cfg = saved.config;
|
||
state.dirty = false;
|
||
renderPlanSelect();
|
||
renderAll();
|
||
const errors = saved.validation?.errors?.length || 0;
|
||
toast(errors ? `${errors} Fehler beim Speichern.` : "Gespeichert.", errors ? "err" : "ok");
|
||
}
|
||
|
||
async function publishPlan() {
|
||
if (!state.activePlanId) return;
|
||
syncFormStateFromDom();
|
||
if (state.dirty) await savePlan();
|
||
const saved = await api(`/api/plans/${encodeURIComponent(state.activePlanId)}/publish`, {
|
||
method: "POST",
|
||
body: JSON.stringify({expected_revision: state.wrapper.revision}),
|
||
});
|
||
state.wrapper = saved;
|
||
state.cfg = saved.config;
|
||
state.dirty = false;
|
||
await loadPlans(state.activePlanId);
|
||
toast("Veröffentlicht.", "ok");
|
||
}
|
||
|
||
$("tabs").addEventListener("click", (event) => {
|
||
const button = event.target.closest("button[data-tab]");
|
||
if (!button) return;
|
||
state.tab = button.dataset.tab;
|
||
renderTabs();
|
||
if (state.tab === "json") renderJson();
|
||
});
|
||
|
||
function handleFormEdit(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();
|
||
} else 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) return;
|
||
const key = target.dataset.exerciseField;
|
||
if (target.value.trim() || key === "name") exercise[key] = target.value;
|
||
else delete exercise[key];
|
||
markDirty();
|
||
}
|
||
const progressionRow = target.closest("[data-progression-id]");
|
||
if (exerciseRow && progressionRow && target.dataset.progressionField) {
|
||
const exercise = state.cfg.exercises.find((item) => item.id === exerciseRow.dataset.exerciseId);
|
||
const progression = (exercise?.progressions || []).find((item) => item.id === progressionRow.dataset.progressionId);
|
||
if (!progression) return;
|
||
progression[target.dataset.progressionField] = target.value;
|
||
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 (target.value.trim()) item[key] = target.value;
|
||
else delete item[key];
|
||
markDirty();
|
||
if (key === "exercise_id") renderAll();
|
||
} else if (itemRow && target.dataset.dayCurrentProgression !== undefined) {
|
||
const item = (day.exercises || []).find((entry) => entry.id === itemRow.dataset.dayItem);
|
||
const exercise = state.cfg.exercises.find((entry) => entry.id === item?.exercise_id);
|
||
if (!exercise) return;
|
||
exercise.current_progression_id = target.value;
|
||
markDirty();
|
||
renderAll();
|
||
}
|
||
}
|
||
}
|
||
|
||
document.addEventListener("input", handleFormEdit);
|
||
document.addEventListener("change", handleFormEdit);
|
||
|
||
document.addEventListener("click", async (event) => {
|
||
const button = event.target.closest("button");
|
||
if (!button) return;
|
||
if (!state.cfg && button.id !== "btnNew") return;
|
||
try {
|
||
if (button.id === "btnNew") {
|
||
const name = prompt("Name des neuen Plans:", "Neuer Trainingsplan");
|
||
if (!name) return;
|
||
const created = await api("/api/plans", {method: "POST", body: JSON.stringify({name})});
|
||
await loadPlans(created.id);
|
||
} else if (button.id === "btnRename") {
|
||
const name = prompt("Neuer Name:", state.wrapper?.name || "");
|
||
if (!name) return;
|
||
await api(`/api/plans/${encodeURIComponent(state.activePlanId)}/rename`, {method: "POST", body: JSON.stringify({name})});
|
||
await loadPlans(state.activePlanId);
|
||
} else if (button.id === "btnDelete") {
|
||
if (!confirm("Plan löschen?")) return;
|
||
await api(`/api/plans/${encodeURIComponent(state.activePlanId)}`, {method: "DELETE"});
|
||
await loadPlans();
|
||
} else if (button.id === "btnSave") {
|
||
await savePlan();
|
||
} else if (button.id === "btnPublish") {
|
||
await publishPlan();
|
||
} else if (button.dataset.addDay !== undefined) {
|
||
addDay();
|
||
} else if (button.dataset.addExercise !== undefined) {
|
||
addExerciseFromForm();
|
||
} else if (button.dataset.applyJson !== undefined) {
|
||
state.cfg = JSON.parse($("jsonEditor").value);
|
||
state.cfg.exercises = state.cfg.exercises || [];
|
||
state.cfg.days = state.cfg.days || [];
|
||
markDirty();
|
||
renderAll();
|
||
}
|
||
|
||
const dayShell = button.closest("[data-day-id]");
|
||
if (dayShell) {
|
||
const dayIndex = state.cfg.days.findIndex((day) => day.id === dayShell.dataset.dayId);
|
||
const day = state.cfg.days[dayIndex];
|
||
if (!day) return;
|
||
if (button.dataset.moveDay) {
|
||
move(state.cfg.days, dayIndex, Number(button.dataset.moveDay));
|
||
} else if (button.dataset.removeDay !== undefined) {
|
||
if (!confirm("Tag löschen?")) return;
|
||
state.cfg.days.splice(dayIndex, 1);
|
||
markDirty();
|
||
renderAll();
|
||
} else if (button.dataset.addDayExercise !== undefined) {
|
||
const exerciseId = dayShell.querySelector("[data-new-day-exercise]")?.value;
|
||
if (!exerciseId) return toast("Keine Übung ausgewählt.", "err");
|
||
day.exercises = day.exercises || [];
|
||
day.exercises.push({id: uid("day-exercise"), exercise_id: exerciseId});
|
||
markDirty();
|
||
renderAll();
|
||
}
|
||
const itemRow = button.closest("[data-day-item]");
|
||
if (itemRow) {
|
||
const itemIndex = (day.exercises || []).findIndex((item) => item.id === itemRow.dataset.dayItem);
|
||
if (button.dataset.moveDayItem) {
|
||
move(day.exercises, itemIndex, Number(button.dataset.moveDayItem));
|
||
} else if (button.dataset.removeDayItem !== undefined) {
|
||
day.exercises.splice(itemIndex, 1);
|
||
markDirty();
|
||
renderAll();
|
||
}
|
||
}
|
||
}
|
||
|
||
const exerciseRow = button.closest("[data-exercise-id]");
|
||
if (exerciseRow && button.dataset.addProgression !== undefined) {
|
||
const exercise = state.cfg.exercises.find((item) => item.id === exerciseRow.dataset.exerciseId);
|
||
if (!exercise) return;
|
||
exercise.progressions = exercise.progressions || [];
|
||
const progression = {id: uid("progression"), name: "Neue Progression"};
|
||
exercise.progressions.push(progression);
|
||
if (!exercise.current_progression_id) exercise.current_progression_id = progression.id;
|
||
markDirty();
|
||
renderAll();
|
||
} else if (exerciseRow && button.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);
|
||
state.cfg.days.forEach((day) => {
|
||
day.exercises = (day.exercises || []).filter((item) => item.exercise_id !== id);
|
||
});
|
||
markDirty();
|
||
renderAll();
|
||
}
|
||
if (exerciseRow) {
|
||
const exercise = state.cfg.exercises.find((item) => item.id === exerciseRow.dataset.exerciseId);
|
||
const progressionRow = button.closest("[data-progression-id]");
|
||
if (exercise && progressionRow) {
|
||
exercise.progressions = exercise.progressions || [];
|
||
const progressionIndex = exercise.progressions.findIndex((item) => item.id === progressionRow.dataset.progressionId);
|
||
if (button.dataset.moveProgression) {
|
||
move(exercise.progressions, progressionIndex, Number(button.dataset.moveProgression));
|
||
} else if (button.dataset.removeProgression !== undefined && progressionIndex >= 0) {
|
||
const [removed] = exercise.progressions.splice(progressionIndex, 1);
|
||
if (removed?.id === exercise.current_progression_id) {
|
||
exercise.current_progression_id = exercise.progressions[0]?.id || "";
|
||
}
|
||
markDirty();
|
||
renderAll();
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
toast(err.message, "err");
|
||
}
|
||
});
|
||
|
||
$("planSelect").addEventListener("change", async (event) => {
|
||
if (state.dirty && !confirm("Ungespeicherte Änderungen verwerfen?")) {
|
||
renderPlanSelect();
|
||
return;
|
||
}
|
||
try {
|
||
await api(`/api/plans/${encodeURIComponent(event.target.value)}/select`, {method: "POST"});
|
||
await loadPlan(event.target.value);
|
||
} catch (err) {
|
||
toast(err.message, "err");
|
||
}
|
||
});
|
||
|
||
window.addEventListener("beforeunload", (event) => {
|
||
if (!state.dirty) return;
|
||
event.preventDefault();
|
||
event.returnValue = "";
|
||
});
|
||
|
||
const ready = window.BT?.ready || Promise.resolve();
|
||
ready.then(() => loadPlans()).catch((err) => showError(err.message));
|
||
</script>
|
||
</body>
|
||
</html>
|