UI
This commit is contained in:
@@ -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) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||
}[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>
|
||||
Reference in New Issue
Block a user