From d8f439a3e595c1e59d53acfc4646680c0da2e297 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 27 Apr 2026 14:48:46 +0200 Subject: [PATCH] feat: enhance exercise management with training types and rich text support - Added support for training types in exercise creation and updates, allowing for better categorization of exercises. - Implemented a rich text editor for exercise descriptions, improving content formatting capabilities. - Updated the ExerciseDetailPage to display training types and enhanced the layout for better user experience. - Refactored ExerciseFormPage to accommodate new multi-association fields for training styles, types, and target groups. - Improved API payload handling to include training types and ensure proper data structure for exercise management. - Enhanced the ExercisesListPage with improved loading and filtering functionalities for better performance. --- backend/routers/exercises.py | 25 +- frontend/src/app.css | 191 +++++ frontend/src/components/RichTextEditor.jsx | 93 +++ frontend/src/pages/ExerciseDetailPage.jsx | 318 ++++---- frontend/src/pages/ExerciseFormPage.jsx | 905 ++++++++++++--------- frontend/src/pages/ExercisesListPage.jsx | 287 ++++--- frontend/src/utils/api.js | 45 +- frontend/src/utils/htmlUtils.js | 23 + 8 files changed, 1198 insertions(+), 689 deletions(-) create mode 100644 frontend/src/components/RichTextEditor.jsx create mode 100644 frontend/src/utils/htmlUtils.js diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 7ba23ba..c7c4f6c 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -54,6 +54,7 @@ class ExerciseCreate(BaseModel): # M:N Relations (Liste von {id: int, is_primary: bool}) focus_areas_multi: list[dict] = [] training_styles_multi: list[dict] = [] + training_types_multi: list[dict] = [] target_groups_multi: list[dict] = [] age_groups: list[str] = [] # ["Kinder", "Teenager"] aus Katalog @@ -91,6 +92,7 @@ class ExerciseUpdate(BaseModel): equipment: Optional[list[str]] = None focus_areas_multi: Optional[list[dict]] = None training_styles_multi: Optional[list[dict]] = None + training_types_multi: Optional[list[dict]] = None target_groups_multi: Optional[list[dict]] = None age_groups: Optional[list[str]] = None skills: Optional[list[dict]] = None @@ -227,6 +229,17 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict: ) exercise["training_styles"] = [r2d(r) for r in cur.fetchall()] + # Trainingsstil (Breitensport / Leistungssport …) — exercise_training_types + cur.execute( + """SELECT ett.id, ett.training_type_id, tt.name, tt.abbreviation, ett.is_primary + FROM exercise_training_types ett + JOIN training_types tt ON ett.training_type_id = tt.id + WHERE ett.exercise_id = %s + ORDER BY ett.is_primary DESC, tt.sort_order NULLS LAST, tt.name""", + (exercise_id,), + ) + exercise["training_types"] = [r2d(r) for r in cur.fetchall()] + # Target Groups (M:N) cur.execute( """SELECT etg.id, etg.target_group_id, tg.name, tg.description, etg.is_primary @@ -301,7 +314,7 @@ def assign_exercise_relations(cur, conn, exercise_id: int, data: dict): (exercise_id, fa["focus_area_id"], fa.get("is_primary", False)) ) - # Training Styles + # Training Styles (Stilrichtungen, z. B. Shotokan) if "training_styles_multi" in data: cur.execute("DELETE FROM exercise_style_directions WHERE exercise_id = %s", (exercise_id,)) for ts in data["training_styles_multi"]: @@ -311,6 +324,16 @@ def assign_exercise_relations(cur, conn, exercise_id: int, data: dict): (exercise_id, ts["training_style_id"], ts.get("is_primary", False)) ) + # Trainingsstil (Breitensport, Leistungssport, …) + if "training_types_multi" in data: + cur.execute("DELETE FROM exercise_training_types WHERE exercise_id = %s", (exercise_id,)) + for tt in data["training_types_multi"]: + cur.execute( + """INSERT INTO exercise_training_types (exercise_id, training_type_id, is_primary) + VALUES (%s, %s, %s)""", + (exercise_id, tt["training_type_id"], tt.get("is_primary", False)), + ) + # Target Groups if "target_groups_multi" in data: cur.execute("DELETE FROM exercise_target_groups WHERE exercise_id = %s", (exercise_id,)) diff --git a/frontend/src/app.css b/frontend/src/app.css index 9e7c2ee..d4b2d59 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -2217,3 +2217,194 @@ a.analysis-split__nav-item { gap: 10px; } } + +/* --- Übungen: Rich-Text & Kacheln --- */ +.rich-text-editor-wrap { + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + background: var(--surface); +} +.rich-text-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + padding: 6px 8px; + background: var(--surface2); + border-bottom: 1px solid var(--border); +} +.rte-btn { + font-size: 12px; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--surface); + color: var(--text1); + cursor: pointer; + line-height: 1.2; +} +.rte-btn:active { + background: var(--accent-light); +} +.rte-sep { + width: 1px; + height: 18px; + background: var(--border); + margin: 0 2px; +} +.rich-text-editor { + padding: 10px 12px; + outline: none; + font-size: 15px; + line-height: 1.5; + max-height: 50vh; + overflow-y: auto; +} +.rich-text-editor:empty:before { + content: attr(data-placeholder); + color: var(--text3); + pointer-events: none; +} + +.rich-text-content { + font-size: 16px; + line-height: 1.55; + word-break: break-word; +} +.rich-text-content h3 { + font-size: 1.05rem; + margin: 0.75rem 0 0.35rem; +} +.rich-text-content p { + margin: 0.4rem 0; +} +.rich-text-content ul, +.rich-text-content ol { + margin: 0.4rem 0; + padding-left: 1.25rem; +} +.rich-text-content a { + color: var(--accent-dark); +} + +.exercise-card { + display: flex; + flex-direction: column; + min-height: 200px; +} +.exercise-card__body { + flex: 1 1 auto; +} +.exercise-card__actions { + flex-shrink: 0; + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid var(--border); +} +.exercise-card__actions .btn, +.exercise-card__actions a.btn { + flex: 1 1 auto; + min-width: 0; + padding: 6px 10px; + font-size: 13px; +} + +.exercise-tag-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} +.exercise-tag { + display: inline-block; + font-size: 11px; + font-weight: 600; + padding: 3px 8px; + border-radius: 999px; + background: var(--surface2); + color: var(--text2); + border: 1px solid var(--border); +} +.exercise-tag--accent { + background: var(--accent-light); + color: var(--accent-dark); + border-color: transparent; +} + +.exercise-detail-shell { + max-width: 640px; + margin: 0 auto; +} +.exercise-detail-section { + margin-bottom: 14px; +} +.exercise-detail-section h2 { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text3); + margin: 0 0 6px; + font-weight: 700; +} +.exercise-meta-line { + font-size: 14px; + color: var(--text2); + margin: 8px 0 0; +} + +.exercise-filters-compact { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 8px; + margin-bottom: 12px; +} +.exercise-filters-compact .form-label { + font-size: 12px; + margin-bottom: 4px; +} +.exercise-filters-compact .form-input { + padding: 8px 10px; + font-size: 14px; +} + +.multi-assoc-block { + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px; + margin-bottom: 12px; + background: var(--surface2); +} +.multi-assoc-block h3 { + font-size: 14px; + margin: 0 0 8px; +} +.multi-assoc-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} +.multi-assoc-row select { + flex: 1 1 160px; + min-width: 0; +} + +.skills-editor-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; + align-items: start; + padding: 8px 0; + border-bottom: 1px solid var(--border); +} +@media (min-width: 640px) { + .skills-editor-row { + grid-template-columns: 1fr repeat(4, minmax(0, 100px)) auto; + align-items: center; + } +} diff --git a/frontend/src/components/RichTextEditor.jsx b/frontend/src/components/RichTextEditor.jsx new file mode 100644 index 0000000..5340cd1 --- /dev/null +++ b/frontend/src/components/RichTextEditor.jsx @@ -0,0 +1,93 @@ +import React, { useRef, useEffect, useState, useCallback } from 'react' + +function exec(cmd, value = null) { + try { + document.execCommand(cmd, false, value) + } catch (_) { + /* ignore */ + } +} + +/** + * Leichter WYSIWYG-Editor (contenteditable) — ohne zusätzliche npm-Pakete. + * Speichert HTML; Anzeige über sanitizeTrainerHtml + dangerouslySetInnerHTML. + */ +export default function RichTextEditor({ value, onChange, placeholder, minHeight = '140px' }) { + const ref = useRef(null) + const [focused, setFocused] = useState(false) + const lastExternal = useRef(value) + + useEffect(() => { + if (!ref.current) return + if (focused) return + if (value !== lastExternal.current) { + lastExternal.current = value + if (ref.current.innerHTML !== (value || '')) { + ref.current.innerHTML = value || '' + } + } + }, [value, focused]) + + const sync = useCallback(() => { + if (!ref.current) return + const html = ref.current.innerHTML + lastExternal.current = html + onChange(html) + }, [onChange]) + + const onLink = () => { + const url = window.prompt('Link-URL (https://…)') + if (url) exec('createLink', url) + sync() + } + + return ( +
+
+ + + + + + + + + + + +
+
setFocused(true)} + onBlur={() => { + setFocused(false) + sync() + }} + onInput={sync} + /> +
+ ) +} diff --git a/frontend/src/pages/ExerciseDetailPage.jsx b/frontend/src/pages/ExerciseDetailPage.jsx index b06f31d..ec8b5dd 100644 --- a/frontend/src/pages/ExerciseDetailPage.jsx +++ b/frontend/src/pages/ExerciseDetailPage.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import api from '../utils/api' +import { sanitizeTrainerHtml } from '../utils/htmlUtils' const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '') @@ -11,6 +12,17 @@ function resolveMediaUrl(filePath) { return `${API_BASE}${p}` } +function HtmlBlock({ html, className = '' }) { + if (!html || !String(html).trim()) return null + const safe = sanitizeTrainerHtml(html) + return ( +
+ ) +} + function MediaBlock({ media }) { if (media.embed_url) { return ( @@ -47,6 +59,54 @@ function MediaBlock({ media }) { ) } +function TagRow({ exercise }) { + const tags = [] + ;(exercise.focus_areas || []).forEach((f) => { + tags.push({ key: `fa-${f.id}`, label: f.name, accent: !!f.is_primary }) + }) + ;(exercise.training_styles || []).forEach((t) => { + tags.push({ key: `ts-${t.id}`, label: t.name, accent: false }) + }) + ;(exercise.training_types || []).forEach((t) => { + tags.push({ key: `tt-${t.id}`, label: t.name, accent: false }) + }) + ;(exercise.target_groups || []).forEach((g) => { + tags.push({ key: `tg-${g.id}`, label: g.name, accent: !!g.is_primary }) + }) + ;(exercise.age_groups || []).forEach((ag) => { + tags.push({ key: `ag-${ag}`, label: ag, accent: false }) + }) + if (tags.length === 0) return null + return ( +
+ {tags.map((t) => ( + + {t.label} + + ))} +
+ ) +} + +function metaParts(exercise) { + const parts = [] + if (exercise.duration_min != null || exercise.duration_max != null) { + const a = exercise.duration_min + const b = exercise.duration_max + if (a != null && b != null && a !== b) parts.push(`${a}–${b} Min.`) + else if (a != null) parts.push(`ca. ${a} Min.`) + else if (b != null) parts.push(`ca. ${b} Min.`) + } + if (exercise.group_size_min != null || exercise.group_size_max != null) { + const a = exercise.group_size_min + const b = exercise.group_size_max + if (a != null && b != null && a !== b) parts.push(`Gruppe ${a}–${b}`) + else if (a != null) parts.push(`Gruppe ab ${a}`) + else if (b != null) parts.push(`Gruppe bis ${b}`) + } + return parts +} + function ExerciseDetailPage() { const { id } = useParams() const navigate = useNavigate() @@ -86,7 +146,7 @@ function ExerciseDetailPage() { if (error) { const msg = error.message || String(error) return ( -
+

Übung

{msg}

@@ -100,147 +160,127 @@ function ExerciseDetailPage() { if (!exercise) return null - const chips = (items, labelKey = 'name') => - (items || []).length ? (items || []).map((x) => x[labelKey]).join(', ') : '—' + const meta = metaParts(exercise) return ( -
-
-
- -
- -
-
-

{exercise.title}

- - Bearbeiten - -
- {exercise.summary && ( -

{exercise.summary}

- )} -
- {exercise.visibility} - {exercise.status} - {exercise.club_name && {exercise.club_name}} -
-
- -
-

Zuordnung

-

- Fokusbereiche: {chips(exercise.focus_areas)} -

-

- Stilrichtungen: {chips(exercise.training_styles)} -

-

- Zielgruppen: {chips(exercise.target_groups)} -

-

- Altersgruppen:{' '} - {(exercise.age_groups || []).length ? exercise.age_groups.join(', ') : '—'} -

-
- - {exercise.goal && ( -
-

Ziel

-

{exercise.goal}

-
- )} - - {exercise.execution && ( -
-

Durchführung

-

{exercise.execution}

-
- )} - - {(exercise.preparation || exercise.trainer_notes) && ( -
-

Trainer

- {exercise.preparation && ( - <> -

Vorbereitung

-

{exercise.preparation}

- - )} - {exercise.trainer_notes && ( - <> -

Hinweise

-

{exercise.trainer_notes}

- - )} -
- )} - - {exercise.equipment && exercise.equipment.length > 0 && ( -
-

Material

-
    - {exercise.equipment.map((x, i) => ( -
  • {x}
  • - ))} -
-
- )} - - {(exercise.skills || []).length > 0 && ( -
-

Fähigkeiten

-
    - {exercise.skills.map((s) => ( -
  • - {s.skill_name} - {s.skill_category ? ` (${s.skill_category})` : ''} - {s.is_primary ? ' · primär' : ''} - {s.intensity ? ` · ${s.intensity}` : ''} -
  • - ))} -
-
- )} - - {(exercise.variants || []).length > 0 && ( -
-

Varianten

- {exercise.variants.map((v) => ( -
- {v.variant_name} - {v.description &&

{v.description}

} - {v.execution_changes && ( -

{v.execution_changes}

- )} -
- ))} -
- )} - - {(exercise.media || []).length > 0 && ( -
-

Medien

- {exercise.media.map((m) => ( -
- {m.title || m.original_filename || m.media_type} - {m.description &&

{m.description}

} - -
- ))} -
- )} +
+
+ + + Bearbeiten +
+ +
+

{exercise.title}

+ {exercise.summary && ( +
+ +
+ )} + +
+ {exercise.visibility} + {exercise.status} + {exercise.club_name && {exercise.club_name}} +
+ {meta.length > 0 &&

{meta.join(' · ')}

} +
+ + {exercise.goal && ( +
+

Ziel

+ +
+ )} + + {(exercise.equipment || []).length > 0 && ( +
+

Material & Aufbau

+
    + {exercise.equipment.map((x, i) => ( +
  • {x}
  • + ))} +
+
+ )} + + {exercise.preparation && ( +
+

Vorbereitung

+ +
+ )} + + {exercise.execution && ( +
+

Ablauf

+ +
+ )} + + {(exercise.media || []).length > 0 && ( +
+

Medien

+ {exercise.media.map((m) => ( +
+ {m.title || m.original_filename || m.media_type} + {m.description &&

{m.description}

} + +
+ ))} +
+ )} + + {exercise.trainer_notes && ( +
+

Hinweise für Trainer

+ +
+ )} + + {(exercise.skills || []).length > 0 && ( +
+

Fähigkeiten

+
+ {exercise.skills.map((s) => ( + + {s.skill_name} + {s.intensity ? ` · ${s.intensity}` : ''} + {s.required_level || s.target_level + ? ` (${[s.required_level, s.target_level].filter(Boolean).join(' → ')})` + : ''} + + ))} +
+
+ )} + + {(exercise.variants || []).length > 0 && ( +
+

Varianten

+ {exercise.variants.map((v) => ( +
+ {v.variant_name} + {v.description &&

{v.description}

} + {v.execution_changes && ( +
+ +
+ )} +
+ ))} +
+ )}
) } diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx index ea36c83..9f720f8 100644 --- a/frontend/src/pages/ExerciseFormPage.jsx +++ b/frontend/src/pages/ExerciseFormPage.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import api, { buildExerciseApiPayload } from '../utils/api' +import RichTextEditor from '../components/RichTextEditor' const INTENSITY_OPTIONS = [ { value: '', label: '—' }, @@ -18,6 +19,8 @@ const LEVEL_OPTIONS = [ { value: 'experte', label: 'Experte' }, ] +const AGE_GROUP_OPTIONS = ['Minis', 'Kinder', 'Schüler', 'Teenager', 'Erwachsene'] + function emptyForm() { return { title: '', @@ -26,14 +29,16 @@ function emptyForm() { execution: '', preparation: '', trainer_notes: '', - equipment: [], + equipmentLines: '', duration_min: '', duration_max: '', group_size_min: '', group_size_max: '', age_groups: [], - focus_area_id: null, - training_style_id: null, + focus_areas_multi: [], + training_styles_multi: [], + training_types_multi: [], + target_groups_multi: [], visibility: 'private', status: 'draft', skills: [], @@ -41,9 +46,6 @@ function emptyForm() { } function detailToForm(exercise) { - const primaryFa = exercise.focus_areas?.find((f) => f.is_primary) || exercise.focus_areas?.[0] - const primaryTs = - exercise.training_styles?.find((t) => t.is_primary) || exercise.training_styles?.[0] return { title: exercise.title || '', summary: exercise.summary || '', @@ -51,14 +53,28 @@ function detailToForm(exercise) { execution: exercise.execution || '', preparation: exercise.preparation || '', trainer_notes: exercise.trainer_notes || '', - equipment: exercise.equipment || [], + equipmentLines: (exercise.equipment || []).join('\n'), duration_min: exercise.duration_min ?? '', duration_max: exercise.duration_max ?? '', group_size_min: exercise.group_size_min ?? '', group_size_max: exercise.group_size_max ?? '', age_groups: exercise.age_groups || [], - focus_area_id: primaryFa?.focus_area_id ?? null, - training_style_id: primaryTs?.training_style_id ?? null, + focus_areas_multi: (exercise.focus_areas || []).map((f) => ({ + focus_area_id: f.focus_area_id, + is_primary: !!f.is_primary, + })), + training_styles_multi: (exercise.training_styles || []).map((t) => ({ + training_style_id: t.training_style_id, + is_primary: !!t.is_primary, + })), + training_types_multi: (exercise.training_types || []).map((t) => ({ + training_type_id: t.training_type_id, + is_primary: !!t.is_primary, + })), + target_groups_multi: (exercise.target_groups || []).map((g) => ({ + target_group_id: g.target_group_id, + is_primary: !!g.is_primary, + })), visibility: exercise.visibility || 'private', status: exercise.status || 'draft', skills: @@ -72,6 +88,71 @@ function detailToForm(exercise) { } } +function MultiAssocBlock({ title, rows, setRows, options, idKey, emptyLabel }) { + const setPrimary = (idx) => { + setRows(rows.map((r, i) => ({ ...r, is_primary: i === idx }))) + } + const updateRow = (idx, patch) => { + const next = rows.map((r, i) => (i === idx ? { ...r, ...patch } : r)) + if (patch.is_primary === true) { + next.forEach((r, i) => { + if (i !== idx) r.is_primary = false + }) + } + setRows(next) + } + const addRow = () => setRows([...rows, { [idKey]: '', is_primary: rows.length === 0 }]) + const removeRow = (idx) => { + const next = rows.filter((_, i) => i !== idx) + if (next.length && !next.some((r) => r.is_primary)) next[0].is_primary = true + setRows(next) + } + + return ( +
+
+

{title}

+ +
+ {rows.length === 0 && ( +

{emptyLabel}

+ )} + {rows.map((row, idx) => ( +
+ + + +
+ ))} +
+ ) +} + function ExerciseFormPage() { const { id: routeId } = useParams() const navigate = useNavigate() @@ -81,10 +162,13 @@ function ExerciseFormPage() { const [formData, setFormData] = useState(emptyForm) const [skillsCatalog, setSkillsCatalog] = useState([]) const [focusAreas, setFocusAreas] = useState([]) - const [trainingStyles, setTrainingStyles] = useState([]) + const [styleDirections, setStyleDirections] = useState([]) + const [trainingTypes, setTrainingTypes] = useState([]) + const [targetGroups, setTargetGroups] = useState([]) const [mediaList, setMediaList] = useState([]) const [loading, setLoading] = useState(!!isEdit) const [saving, setSaving] = useState(false) + const [skillPick, setSkillPick] = useState('') const [mediaFile, setMediaFile] = useState(null) const [mediaType, setMediaType] = useState('image') @@ -97,15 +181,19 @@ function ExerciseFormPage() { let cancelled = false const boot = async () => { try { - const [skillsData, faData, tsData] = await Promise.all([ + const [skillsData, faData, sdData, ttData, tgData] = await Promise.all([ api.listSkills(), api.listFocusAreas(), api.listTrainingStyles(), + api.listTrainingTypes(), + api.listTargetGroups(), ]) if (cancelled) return setSkillsCatalog(skillsData) setFocusAreas(faData) - setTrainingStyles(tsData) + setStyleDirections(sdData) + setTrainingTypes(ttData) + setTargetGroups(tgData) } catch (e) { if (!cancelled) console.error(e) } @@ -150,43 +238,75 @@ function ExerciseFormPage() { setFormData((prev) => ({ ...prev, [field]: value })) } - const toggleSkill = (skillId) => { - const existing = formData.skills.find((s) => s.skill_id === skillId) - if (existing) { - updateFormField( - 'skills', - formData.skills.filter((s) => s.skill_id !== skillId), - ) - } else { - updateFormField('skills', [ - ...formData.skills, - { - skill_id: skillId, - is_primary: false, - intensity: '', - required_level: '', - target_level: '', - }, - ]) - } + const toggleAgeGroup = (name) => { + const set = new Set(formData.age_groups) + if (set.has(name)) set.delete(name) + else set.add(name) + updateFormField('age_groups', [...set]) } - const updateSkillField = (skillId, field, value) => { + const addSkillRow = () => { + const id = skillPick ? parseInt(skillPick, 10) : null + if (!id) { + alert('Fähigkeit wählen') + return + } + if (formData.skills.some((s) => s.skill_id === id)) { + alert('Bereits zugeordnet') + return + } + updateFormField('skills', [ + ...formData.skills, + { + skill_id: id, + is_primary: formData.skills.length === 0, + intensity: '', + required_level: '', + target_level: '', + }, + ]) + setSkillPick('') + } + + const setSkillPrimary = (idx) => { updateFormField( 'skills', - formData.skills.map((s) => (s.skill_id === skillId ? { ...s, [field]: value } : s)), + formData.skills.map((s, i) => ({ ...s, is_primary: i === idx })), ) } + const updateSkillField = (idx, field, value) => { + updateFormField( + 'skills', + formData.skills.map((s, i) => (i === idx ? { ...s, [field]: value } : s)), + ) + } + + const removeSkillRow = (idx) => { + const next = formData.skills.filter((_, i) => i !== idx) + if (next.length && !next.some((s) => s.is_primary)) next[0].is_primary = true + updateFormField('skills', next) + } + const handleSubmit = async (e) => { e.preventDefault() if (!formData.title || formData.title.trim().length < 3) { alert('Titel mindestens 3 Zeichen') return } + const payloadBase = { + ...formData, + equipment: + typeof formData.equipmentLines === 'string' + ? formData.equipmentLines + .split(/[\n,]+/) + .map((s) => s.trim()) + .filter(Boolean) + : [], + } let payload try { - payload = buildExerciseApiPayload(formData) + payload = buildExerciseApiPayload(payloadBase) } catch (err) { alert(err.message) return @@ -269,6 +389,8 @@ function ExerciseFormPage() { } } + const availableSkills = skillsCatalog.filter((s) => !formData.skills.some((x) => x.skill_id === s.id)) + if (loading) { return (
@@ -279,416 +401,419 @@ function ExerciseFormPage() { } return ( -
-
-
- + {isEdit && ( + - {isEdit && ( - - )} -
+ )} +
-
-

{isEdit ? 'Übung bearbeiten' : 'Neue Übung'}

+
+

{isEdit ? 'Übung bearbeiten' : 'Neue Übung'}

-
+ +
+ + updateFormField('title', e.target.value)} + required + minLength={3} + /> +
+ +
+ + updateFormField('summary', html)} + placeholder="Kurzbeschreibung (optional)" + minHeight="80px" + /> +
+ +
+ + updateFormField('goal', html)} + placeholder="Trainingsziel" + minHeight="120px" + /> +
+ +
+ + updateFormField('execution', html)} + placeholder="Ablauf Schritt für Schritt" + minHeight="180px" + /> +
+ +
+ + updateFormField('preparation', html)} + placeholder="Matten, Raum, …" + minHeight="100px" + /> +
+ +
+ + updateFormField('trainer_notes', html)} + placeholder="Sicherheit, Varianten-Hinweise, …" + minHeight="100px" + /> +
+ +
+ +