feat: enhance exercise management with training types and rich text support
Some checks failed
Deploy Development / deploy (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 1m58s

- 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.
This commit is contained in:
Lars 2026-04-27 14:48:46 +02:00
parent cb11e39201
commit d8f439a3e5
8 changed files with 1198 additions and 689 deletions

View File

@ -54,6 +54,7 @@ class ExerciseCreate(BaseModel):
# M:N Relations (Liste von {id: int, is_primary: bool}) # M:N Relations (Liste von {id: int, is_primary: bool})
focus_areas_multi: list[dict] = [] focus_areas_multi: list[dict] = []
training_styles_multi: list[dict] = [] training_styles_multi: list[dict] = []
training_types_multi: list[dict] = []
target_groups_multi: list[dict] = [] target_groups_multi: list[dict] = []
age_groups: list[str] = [] # ["Kinder", "Teenager"] aus Katalog age_groups: list[str] = [] # ["Kinder", "Teenager"] aus Katalog
@ -91,6 +92,7 @@ class ExerciseUpdate(BaseModel):
equipment: Optional[list[str]] = None equipment: Optional[list[str]] = None
focus_areas_multi: Optional[list[dict]] = None focus_areas_multi: Optional[list[dict]] = None
training_styles_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 target_groups_multi: Optional[list[dict]] = None
age_groups: Optional[list[str]] = None age_groups: Optional[list[str]] = None
skills: Optional[list[dict]] = 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()] 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) # Target Groups (M:N)
cur.execute( cur.execute(
"""SELECT etg.id, etg.target_group_id, tg.name, tg.description, etg.is_primary """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)) (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: if "training_styles_multi" in data:
cur.execute("DELETE FROM exercise_style_directions WHERE exercise_id = %s", (exercise_id,)) cur.execute("DELETE FROM exercise_style_directions WHERE exercise_id = %s", (exercise_id,))
for ts in data["training_styles_multi"]: 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)) (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 # Target Groups
if "target_groups_multi" in data: if "target_groups_multi" in data:
cur.execute("DELETE FROM exercise_target_groups WHERE exercise_id = %s", (exercise_id,)) cur.execute("DELETE FROM exercise_target_groups WHERE exercise_id = %s", (exercise_id,))

View File

@ -2217,3 +2217,194 @@ a.analysis-split__nav-item {
gap: 10px; 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;
}
}

View File

@ -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 (
<div className="rich-text-editor-wrap">
<div className="rich-text-toolbar" role="toolbar" aria-label="Formatierung">
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('bold')}>
<strong>B</strong>
</button>
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('italic')}>
<em>I</em>
</button>
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('underline')}>
U
</button>
<span className="rte-sep" />
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('formatBlock', 'h3')}>
H3
</button>
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('insertUnorderedList')}>
</button>
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('insertOrderedList')}>
1.
</button>
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('formatBlock', 'p')}>
</button>
<span className="rte-sep" />
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={onLink}>
Link
</button>
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('removeFormat')}>
</button>
</div>
<div
ref={ref}
className="rich-text-editor"
contentEditable
suppressContentEditableWarning
data-placeholder={placeholder || ''}
style={{ minHeight }}
onFocus={() => setFocused(true)}
onBlur={() => {
setFocused(false)
sync()
}}
onInput={sync}
/>
</div>
)
}

View File

@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import { sanitizeTrainerHtml } from '../utils/htmlUtils'
const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '') const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
@ -11,6 +12,17 @@ function resolveMediaUrl(filePath) {
return `${API_BASE}${p}` return `${API_BASE}${p}`
} }
function HtmlBlock({ html, className = '' }) {
if (!html || !String(html).trim()) return null
const safe = sanitizeTrainerHtml(html)
return (
<div
className={`rich-text-content ${className}`}
dangerouslySetInnerHTML={{ __html: safe }}
/>
)
}
function MediaBlock({ media }) { function MediaBlock({ media }) {
if (media.embed_url) { if (media.embed_url) {
return ( 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 (
<div className="exercise-tag-row">
{tags.map((t) => (
<span key={t.key} className={`exercise-tag${t.accent ? ' exercise-tag--accent' : ''}`}>
{t.label}
</span>
))}
</div>
)
}
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() { function ExerciseDetailPage() {
const { id } = useParams() const { id } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
@ -86,7 +146,7 @@ function ExerciseDetailPage() {
if (error) { if (error) {
const msg = error.message || String(error) const msg = error.message || String(error)
return ( return (
<div style={{ padding: '2rem', maxWidth: '720px', margin: '0 auto' }}> <div style={{ padding: '1rem', maxWidth: '640px', margin: '0 auto' }}>
<div className="card"> <div className="card">
<h2>Übung</h2> <h2>Übung</h2>
<p style={{ color: 'var(--danger)' }}>{msg}</p> <p style={{ color: 'var(--danger)' }}>{msg}</p>
@ -100,96 +160,46 @@ function ExerciseDetailPage() {
if (!exercise) return null if (!exercise) return null
const chips = (items, labelKey = 'name') => const meta = metaParts(exercise)
(items || []).length ? (items || []).map((x) => x[labelKey]).join(', ') : '—'
return ( return (
<div style={{ padding: '2rem' }}> <div className="exercise-detail-shell" style={{ padding: '12px 12px 24px' }}>
<div style={{ maxWidth: '800px', margin: '0 auto' }}> <div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap' }}>
<div style={{ marginBottom: '1rem' }}>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}> <button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}>
Übersicht Übersicht
</button> </button>
</div>
<div className="card" style={{ marginBottom: '1rem' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '1rem',
flexWrap: 'wrap',
}}
>
<h1 style={{ margin: 0 }}>{exercise.title}</h1>
<Link to={`/exercises/${exercise.id}/edit`} className="btn btn-primary"> <Link to={`/exercises/${exercise.id}/edit`} className="btn btn-primary">
Bearbeiten Bearbeiten
</Link> </Link>
</div> </div>
<div className="card exercise-detail-section">
<h1 style={{ margin: 0, fontSize: '1.35rem', lineHeight: 1.25 }}>{exercise.title}</h1>
{exercise.summary && ( {exercise.summary && (
<p style={{ color: 'var(--text2)', marginTop: '1rem' }}>{exercise.summary}</p> <div style={{ marginTop: '10px', color: 'var(--text2)', fontSize: '15px' }}>
<HtmlBlock html={exercise.summary} />
</div>
)} )}
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '1rem' }}> <TagRow exercise={exercise} />
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginTop: '10px' }}>
<span className="badge">{exercise.visibility}</span> <span className="badge">{exercise.visibility}</span>
<span className="badge">{exercise.status}</span> <span className="badge">{exercise.status}</span>
{exercise.club_name && <span className="badge">{exercise.club_name}</span>} {exercise.club_name && <span className="badge">{exercise.club_name}</span>}
</div> </div>
{meta.length > 0 && <p className="exercise-meta-line">{meta.join(' · ')}</p>}
</div> </div>
<section className="card" style={{ marginBottom: '1rem' }}>
<h2 style={{ marginTop: 0 }}>Zuordnung</h2>
<p>
<strong>Fokusbereiche:</strong> {chips(exercise.focus_areas)}
</p>
<p>
<strong>Stilrichtungen:</strong> {chips(exercise.training_styles)}
</p>
<p>
<strong>Zielgruppen:</strong> {chips(exercise.target_groups)}
</p>
<p>
<strong>Altersgruppen:</strong>{' '}
{(exercise.age_groups || []).length ? exercise.age_groups.join(', ') : '—'}
</p>
</section>
{exercise.goal && ( {exercise.goal && (
<section className="card" style={{ marginBottom: '1rem' }}> <section className="card exercise-detail-section">
<h2 style={{ marginTop: 0 }}>Ziel</h2> <h2>Ziel</h2>
<p style={{ whiteSpace: 'pre-wrap' }}>{exercise.goal}</p> <HtmlBlock html={exercise.goal} />
</section> </section>
)} )}
{exercise.execution && ( {(exercise.equipment || []).length > 0 && (
<section className="card" style={{ marginBottom: '1rem' }}> <section className="card exercise-detail-section">
<h2 style={{ marginTop: 0 }}>Durchführung</h2> <h2>Material &amp; Aufbau</h2>
<p style={{ whiteSpace: 'pre-wrap' }}>{exercise.execution}</p> <ul style={{ paddingLeft: '1.2rem', margin: 0 }}>
</section>
)}
{(exercise.preparation || exercise.trainer_notes) && (
<section className="card" style={{ marginBottom: '1rem' }}>
<h2 style={{ marginTop: 0 }}>Trainer</h2>
{exercise.preparation && (
<>
<h3 style={{ fontSize: '1rem' }}>Vorbereitung</h3>
<p style={{ whiteSpace: 'pre-wrap' }}>{exercise.preparation}</p>
</>
)}
{exercise.trainer_notes && (
<>
<h3 style={{ fontSize: '1rem' }}>Hinweise</h3>
<p style={{ whiteSpace: 'pre-wrap' }}>{exercise.trainer_notes}</p>
</>
)}
</section>
)}
{exercise.equipment && exercise.equipment.length > 0 && (
<section className="card" style={{ marginBottom: '1rem' }}>
<h2 style={{ marginTop: 0 }}>Material</h2>
<ul>
{exercise.equipment.map((x, i) => ( {exercise.equipment.map((x, i) => (
<li key={i}>{x}</li> <li key={i}>{x}</li>
))} ))}
@ -197,50 +207,80 @@ function ExerciseDetailPage() {
</section> </section>
)} )}
{(exercise.skills || []).length > 0 && ( {exercise.preparation && (
<section className="card" style={{ marginBottom: '1rem' }}> <section className="card exercise-detail-section">
<h2 style={{ marginTop: 0 }}>Fähigkeiten</h2> <h2>Vorbereitung</h2>
<ul style={{ paddingLeft: '1.25rem' }}> <HtmlBlock html={exercise.preparation} />
{exercise.skills.map((s) => (
<li key={s.id}>
{s.skill_name}
{s.skill_category ? ` (${s.skill_category})` : ''}
{s.is_primary ? ' · primär' : ''}
{s.intensity ? ` · ${s.intensity}` : ''}
</li>
))}
</ul>
</section> </section>
)} )}
{(exercise.variants || []).length > 0 && ( {exercise.execution && (
<section className="card" style={{ marginBottom: '1rem' }}> <section className="card exercise-detail-section">
<h2 style={{ marginTop: 0 }}>Varianten</h2> <h2>Ablauf</h2>
{exercise.variants.map((v) => ( <HtmlBlock html={exercise.execution} />
<div key={v.id} style={{ marginBottom: '1rem', paddingBottom: '1rem', borderBottom: '1px solid var(--border)' }}>
<strong>{v.variant_name}</strong>
{v.description && <p style={{ color: 'var(--text2)' }}>{v.description}</p>}
{v.execution_changes && (
<p style={{ whiteSpace: 'pre-wrap' }}>{v.execution_changes}</p>
)}
</div>
))}
</section> </section>
)} )}
{(exercise.media || []).length > 0 && ( {(exercise.media || []).length > 0 && (
<section className="card"> <section className="card exercise-detail-section">
<h2 style={{ marginTop: 0 }}>Medien</h2> <h2>Medien</h2>
{exercise.media.map((m) => ( {exercise.media.map((m) => (
<div key={m.id} style={{ marginBottom: '1.5rem' }}> <div key={m.id} style={{ marginBottom: '1.25rem' }}>
<strong>{m.title || m.original_filename || m.media_type}</strong> <strong style={{ fontSize: '15px' }}>{m.title || m.original_filename || m.media_type}</strong>
{m.description && <p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{m.description}</p>} {m.description && <p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{m.description}</p>}
<MediaBlock media={m} /> <MediaBlock media={m} />
</div> </div>
))} ))}
</section> </section>
)} )}
{exercise.trainer_notes && (
<section className="card exercise-detail-section">
<h2>Hinweise für Trainer</h2>
<HtmlBlock html={exercise.trainer_notes} />
</section>
)}
{(exercise.skills || []).length > 0 && (
<section className="card exercise-detail-section">
<h2>Fähigkeiten</h2>
<div className="exercise-tag-row">
{exercise.skills.map((s) => (
<span key={s.id} className={`exercise-tag${s.is_primary ? ' exercise-tag--accent' : ''}`}>
{s.skill_name}
{s.intensity ? ` · ${s.intensity}` : ''}
{s.required_level || s.target_level
? ` (${[s.required_level, s.target_level].filter(Boolean).join(' → ')})`
: ''}
</span>
))}
</div> </div>
</section>
)}
{(exercise.variants || []).length > 0 && (
<section className="card exercise-detail-section">
<h2>Varianten</h2>
{exercise.variants.map((v) => (
<div
key={v.id}
style={{
marginBottom: '1rem',
paddingBottom: '1rem',
borderBottom: '1px solid var(--border)',
}}
>
<strong style={{ fontSize: '15px' }}>{v.variant_name}</strong>
{v.description && <p style={{ color: 'var(--text2)', marginTop: '4px' }}>{v.description}</p>}
{v.execution_changes && (
<div style={{ marginTop: '8px' }}>
<HtmlBlock html={v.execution_changes} />
</div>
)}
</div>
))}
</section>
)}
</div> </div>
) )
} }

View File

@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import api, { buildExerciseApiPayload } from '../utils/api' import api, { buildExerciseApiPayload } from '../utils/api'
import RichTextEditor from '../components/RichTextEditor'
const INTENSITY_OPTIONS = [ const INTENSITY_OPTIONS = [
{ value: '', label: '—' }, { value: '', label: '—' },
@ -18,6 +19,8 @@ const LEVEL_OPTIONS = [
{ value: 'experte', label: 'Experte' }, { value: 'experte', label: 'Experte' },
] ]
const AGE_GROUP_OPTIONS = ['Minis', 'Kinder', 'Schüler', 'Teenager', 'Erwachsene']
function emptyForm() { function emptyForm() {
return { return {
title: '', title: '',
@ -26,14 +29,16 @@ function emptyForm() {
execution: '', execution: '',
preparation: '', preparation: '',
trainer_notes: '', trainer_notes: '',
equipment: [], equipmentLines: '',
duration_min: '', duration_min: '',
duration_max: '', duration_max: '',
group_size_min: '', group_size_min: '',
group_size_max: '', group_size_max: '',
age_groups: [], age_groups: [],
focus_area_id: null, focus_areas_multi: [],
training_style_id: null, training_styles_multi: [],
training_types_multi: [],
target_groups_multi: [],
visibility: 'private', visibility: 'private',
status: 'draft', status: 'draft',
skills: [], skills: [],
@ -41,9 +46,6 @@ function emptyForm() {
} }
function detailToForm(exercise) { 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 { return {
title: exercise.title || '', title: exercise.title || '',
summary: exercise.summary || '', summary: exercise.summary || '',
@ -51,14 +53,28 @@ function detailToForm(exercise) {
execution: exercise.execution || '', execution: exercise.execution || '',
preparation: exercise.preparation || '', preparation: exercise.preparation || '',
trainer_notes: exercise.trainer_notes || '', trainer_notes: exercise.trainer_notes || '',
equipment: exercise.equipment || [], equipmentLines: (exercise.equipment || []).join('\n'),
duration_min: exercise.duration_min ?? '', duration_min: exercise.duration_min ?? '',
duration_max: exercise.duration_max ?? '', duration_max: exercise.duration_max ?? '',
group_size_min: exercise.group_size_min ?? '', group_size_min: exercise.group_size_min ?? '',
group_size_max: exercise.group_size_max ?? '', group_size_max: exercise.group_size_max ?? '',
age_groups: exercise.age_groups || [], age_groups: exercise.age_groups || [],
focus_area_id: primaryFa?.focus_area_id ?? null, focus_areas_multi: (exercise.focus_areas || []).map((f) => ({
training_style_id: primaryTs?.training_style_id ?? null, 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', visibility: exercise.visibility || 'private',
status: exercise.status || 'draft', status: exercise.status || 'draft',
skills: 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 (
<div className="multi-assoc-block">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' }}>
<h3>{title}</h3>
<button type="button" className="btn btn-secondary" style={{ fontSize: '12px', padding: '4px 10px' }} onClick={addRow}>
+ Eintrag
</button>
</div>
{rows.length === 0 && (
<p style={{ fontSize: '13px', color: 'var(--text2)', margin: 0 }}>{emptyLabel}</p>
)}
{rows.map((row, idx) => (
<div key={idx} className="multi-assoc-row">
<select
className="form-input"
value={row[idKey] || ''}
onChange={(e) => updateRow(idx, { [idKey]: e.target.value ? parseInt(e.target.value, 10) : '' })}
>
<option value=""> wählen </option>
{options.map((o) => (
<option key={o.id} value={o.id}>
{o.icon ? `${o.icon} ` : ''}
{o.name}
{o.abbreviation ? ` (${o.abbreviation})` : ''}
</option>
))}
</select>
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '13px', whiteSpace: 'nowrap' }}>
<input
type="radio"
name={`primary-${idKey}`}
checked={!!row.is_primary}
onChange={() => setPrimary(idx)}
/>
primär
</label>
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => removeRow(idx)}>
</button>
</div>
))}
</div>
)
}
function ExerciseFormPage() { function ExerciseFormPage() {
const { id: routeId } = useParams() const { id: routeId } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
@ -81,10 +162,13 @@ function ExerciseFormPage() {
const [formData, setFormData] = useState(emptyForm) const [formData, setFormData] = useState(emptyForm)
const [skillsCatalog, setSkillsCatalog] = useState([]) const [skillsCatalog, setSkillsCatalog] = useState([])
const [focusAreas, setFocusAreas] = 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 [mediaList, setMediaList] = useState([])
const [loading, setLoading] = useState(!!isEdit) const [loading, setLoading] = useState(!!isEdit)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [skillPick, setSkillPick] = useState('')
const [mediaFile, setMediaFile] = useState(null) const [mediaFile, setMediaFile] = useState(null)
const [mediaType, setMediaType] = useState('image') const [mediaType, setMediaType] = useState('image')
@ -97,15 +181,19 @@ function ExerciseFormPage() {
let cancelled = false let cancelled = false
const boot = async () => { const boot = async () => {
try { try {
const [skillsData, faData, tsData] = await Promise.all([ const [skillsData, faData, sdData, ttData, tgData] = await Promise.all([
api.listSkills(), api.listSkills(),
api.listFocusAreas(), api.listFocusAreas(),
api.listTrainingStyles(), api.listTrainingStyles(),
api.listTrainingTypes(),
api.listTargetGroups(),
]) ])
if (cancelled) return if (cancelled) return
setSkillsCatalog(skillsData) setSkillsCatalog(skillsData)
setFocusAreas(faData) setFocusAreas(faData)
setTrainingStyles(tsData) setStyleDirections(sdData)
setTrainingTypes(ttData)
setTargetGroups(tgData)
} catch (e) { } catch (e) {
if (!cancelled) console.error(e) if (!cancelled) console.error(e)
} }
@ -150,43 +238,75 @@ function ExerciseFormPage() {
setFormData((prev) => ({ ...prev, [field]: value })) setFormData((prev) => ({ ...prev, [field]: value }))
} }
const toggleSkill = (skillId) => { const toggleAgeGroup = (name) => {
const existing = formData.skills.find((s) => s.skill_id === skillId) const set = new Set(formData.age_groups)
if (existing) { if (set.has(name)) set.delete(name)
updateFormField( else set.add(name)
'skills', updateFormField('age_groups', [...set])
formData.skills.filter((s) => s.skill_id !== skillId), }
)
} else { 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', [ updateFormField('skills', [
...formData.skills, ...formData.skills,
{ {
skill_id: skillId, skill_id: id,
is_primary: false, is_primary: formData.skills.length === 0,
intensity: '', intensity: '',
required_level: '', required_level: '',
target_level: '', target_level: '',
}, },
]) ])
} setSkillPick('')
} }
const updateSkillField = (skillId, field, value) => { const setSkillPrimary = (idx) => {
updateFormField( updateFormField(
'skills', '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) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault()
if (!formData.title || formData.title.trim().length < 3) { if (!formData.title || formData.title.trim().length < 3) {
alert('Titel mindestens 3 Zeichen') alert('Titel mindestens 3 Zeichen')
return return
} }
const payloadBase = {
...formData,
equipment:
typeof formData.equipmentLines === 'string'
? formData.equipmentLines
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean)
: [],
}
let payload let payload
try { try {
payload = buildExerciseApiPayload(formData) payload = buildExerciseApiPayload(payloadBase)
} catch (err) { } catch (err) {
alert(err.message) alert(err.message)
return return
@ -269,6 +389,8 @@ function ExerciseFormPage() {
} }
} }
const availableSkills = skillsCatalog.filter((s) => !formData.skills.some((x) => x.skill_id === s.id))
if (loading) { if (loading) {
return ( return (
<div style={{ padding: '2rem', textAlign: 'center' }}> <div style={{ padding: '2rem', textAlign: 'center' }}>
@ -279,9 +401,8 @@ function ExerciseFormPage() {
} }
return ( return (
<div style={{ padding: '2rem' }}> <div style={{ padding: '12px', maxWidth: '720px', margin: '0 auto' }}>
<div style={{ maxWidth: '720px', margin: '0 auto' }}> <div style={{ marginBottom: '12px' }}>
<div style={{ marginBottom: '1rem' }}>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}> <button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}>
Übersicht Übersicht
</button> </button>
@ -289,7 +410,7 @@ function ExerciseFormPage() {
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
style={{ marginLeft: '0.5rem' }} style={{ marginLeft: '8px' }}
onClick={() => navigate(`/exercises/${exerciseId}`)} onClick={() => navigate(`/exercises/${exerciseId}`)}
> >
Ansehen Ansehen
@ -298,7 +419,7 @@ function ExerciseFormPage() {
</div> </div>
<div className="card"> <div className="card">
<h1 style={{ marginTop: 0 }}>{isEdit ? 'Übung bearbeiten' : 'Neue Übung'}</h1> <h1 style={{ marginTop: 0, fontSize: '1.25rem' }}>{isEdit ? 'Übung bearbeiten' : 'Neue Übung'}</h1>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="form-row"> <div className="form-row">
@ -315,56 +436,66 @@ function ExerciseFormPage() {
<div className="form-row"> <div className="form-row">
<label className="form-label">Kurzbeschreibung</label> <label className="form-label">Kurzbeschreibung</label>
<textarea <RichTextEditor
className="form-input"
rows={2}
value={formData.summary} value={formData.summary}
onChange={(e) => updateFormField('summary', e.target.value)} onChange={(html) => updateFormField('summary', html)}
placeholder="Kurzbeschreibung (optional)"
minHeight="80px"
/> />
</div> </div>
<div className="form-row"> <div className="form-row">
<label className="form-label">Ziel *</label> <label className="form-label">Ziel *</label>
<textarea <RichTextEditor
className="form-input"
rows={3}
value={formData.goal} value={formData.goal}
onChange={(e) => updateFormField('goal', e.target.value)} onChange={(html) => updateFormField('goal', html)}
placeholder="Mindestens Ziel oder Durchführung ausfüllen (Wiki-Import oft nur eines von beiden)." placeholder="Trainingsziel"
minHeight="120px"
/> />
</div> </div>
<div className="form-row"> <div className="form-row">
<label className="form-label">Durchführung *</label> <label className="form-label">Durchführung *</label>
<textarea <RichTextEditor
className="form-input"
rows={4}
value={formData.execution} value={formData.execution}
onChange={(e) => updateFormField('execution', e.target.value)} onChange={(html) => updateFormField('execution', html)}
placeholder="Ablauf Schritt für Schritt"
minHeight="180px"
/> />
</div> </div>
<div className="form-row"> <div className="form-row">
<label className="form-label">Vorbereitung / Aufbau</label> <label className="form-label">Vorbereitung / Aufbau</label>
<textarea <RichTextEditor
className="form-input"
rows={3}
value={formData.preparation} value={formData.preparation}
onChange={(e) => updateFormField('preparation', e.target.value)} onChange={(html) => updateFormField('preparation', html)}
placeholder="Matten, Raum, …"
minHeight="100px"
/> />
</div> </div>
<div className="form-row"> <div className="form-row">
<label className="form-label">Hinweise für Trainer</label> <label className="form-label">Hinweise für Trainer</label>
<textarea <RichTextEditor
className="form-input"
rows={2}
value={formData.trainer_notes} value={formData.trainer_notes}
onChange={(e) => updateFormField('trainer_notes', e.target.value)} onChange={(html) => updateFormField('trainer_notes', html)}
placeholder="Sicherheit, Varianten-Hinweise, …"
minHeight="100px"
/> />
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}> <div className="form-row">
<label className="form-label">Material (eine Zeile oder kommagetrennt)</label>
<textarea
className="form-input"
rows={3}
value={formData.equipmentLines}
onChange={(e) => updateFormField('equipmentLines', e.target.value)}
placeholder="Matten&#10;Pratzen"
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div className="form-row"> <div className="form-row">
<label className="form-label">Dauer Min</label> <label className="form-label">Dauer Min</label>
<input <input
@ -389,7 +520,7 @@ function ExerciseFormPage() {
</div> </div>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div className="form-row"> <div className="form-row">
<label className="form-label">Gruppe Min</label> <label className="form-label">Gruppe Min</label>
<input <input
@ -397,10 +528,7 @@ function ExerciseFormPage() {
className="form-input" className="form-input"
value={formData.group_size_min} value={formData.group_size_min}
onChange={(e) => onChange={(e) =>
updateFormField( updateFormField('group_size_min', e.target.value ? parseInt(e.target.value, 10) : '')
'group_size_min',
e.target.value ? parseInt(e.target.value, 10) : '',
)
} }
/> />
</div> </div>
@ -411,56 +539,150 @@ function ExerciseFormPage() {
className="form-input" className="form-input"
value={formData.group_size_max} value={formData.group_size_max}
onChange={(e) => onChange={(e) =>
updateFormField( updateFormField('group_size_max', e.target.value ? parseInt(e.target.value, 10) : '')
'group_size_max',
e.target.value ? parseInt(e.target.value, 10) : '',
)
} }
/> />
</div> </div>
</div> </div>
<div className="form-row"> <div className="form-row">
<label className="form-label">Fokusbereich (primär)</label> <label className="form-label">Altersgruppen (Katalog)</label>
<select <div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
className="form-input" {AGE_GROUP_OPTIONS.map((ag) => (
value={formData.focus_area_id || ''} <label key={ag} style={{ fontSize: '14px', display: 'flex', alignItems: 'center', gap: '4px' }}>
onChange={(e) => <input
updateFormField('focus_area_id', e.target.value ? parseInt(e.target.value, 10) : null) type="checkbox"
} checked={formData.age_groups.includes(ag)}
> onChange={() => toggleAgeGroup(ag)}
<option value=""></option> />
{focusAreas.map((fa) => ( {ag}
<option key={fa.id} value={fa.id}> </label>
{fa.icon} {fa.name}
</option>
))} ))}
</select>
</div> </div>
</div>
<MultiAssocBlock
title="Fokusbereiche (0…n, ein „primär“)"
rows={formData.focus_areas_multi}
setRows={(r) => updateFormField('focus_areas_multi', r)}
options={focusAreas}
idKey="focus_area_id"
emptyLabel="Keine Zuordnung — optional „+ Eintrag“."
/>
<MultiAssocBlock
title="Stilrichtungen (0…n, z. B. Shotokan)"
rows={formData.training_styles_multi}
setRows={(r) => updateFormField('training_styles_multi', r)}
options={styleDirections.map((sd) => ({
...sd,
name: sd.parent_style_name ? `${sd.name} (${sd.parent_style_name})` : sd.name,
}))}
idKey="training_style_id"
emptyLabel="Keine Stilrichtung gewählt."
/>
<MultiAssocBlock
title="Trainingsstil (0…n, z. B. Breitensport / Leistungssport)"
rows={formData.training_types_multi}
setRows={(r) => updateFormField('training_types_multi', r)}
options={trainingTypes}
idKey="training_type_id"
emptyLabel="Kein Trainingsstil gewählt."
/>
<MultiAssocBlock
title="Zielgruppen (0…n)"
rows={formData.target_groups_multi}
setRows={(r) => updateFormField('target_groups_multi', r)}
options={targetGroups}
idKey="target_group_id"
emptyLabel="Keine Zielgruppe gewählt."
/>
<div className="form-row"> <div className="form-row">
<label className="form-label">Stilrichtung (primär)</label> <label className="form-label">Fähigkeiten (je Übung mehrere, mit Niveau)</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '10px' }}>
<select <select
className="form-input" className="form-input"
value={formData.training_style_id || ''} style={{ flex: '1 1 200px' }}
onChange={(e) => value={skillPick}
updateFormField( onChange={(e) => setSkillPick(e.target.value)}
'training_style_id',
e.target.value ? parseInt(e.target.value, 10) : null,
)
}
> >
<option value=""></option> <option value="">Fähigkeit wählen</option>
{trainingStyles.map((ts) => ( {availableSkills.map((s) => (
<option key={ts.id} value={ts.id}> <option key={s.id} value={s.id}>
{ts.name} {s.name} ({s.category})
{ts.parent_style_name ? ` (${ts.parent_style_name})` : ''}
</option> </option>
))} ))}
</select> </select>
<button type="button" className="btn btn-secondary" onClick={addSkillRow}>
Hinzufügen
</button>
</div>
{formData.skills.map((row, idx) => {
const sk = skillsCatalog.find((s) => s.id === row.skill_id)
return (
<div key={`${row.skill_id}-${idx}`} className="skills-editor-row">
<div>
<strong style={{ fontSize: '14px' }}>{sk?.name || `Skill #${row.skill_id}`}</strong>
{sk?.category && (
<span style={{ color: 'var(--text2)', fontSize: '12px', marginLeft: '6px' }}>
{sk.category}
</span>
)}
</div>
<label style={{ fontSize: '12px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<input
type="radio"
name="skill-primary"
checked={row.is_primary}
onChange={() => setSkillPrimary(idx)}
/>
primär
</label>
<select
className="form-input"
value={row.intensity || ''}
onChange={(e) => updateSkillField(idx, 'intensity', e.target.value)}
>
{INTENSITY_OPTIONS.map((o) => (
<option key={o.value || 'i'} value={o.value}>
{o.label}
</option>
))}
</select>
<select
className="form-input"
value={row.required_level || ''}
onChange={(e) => updateSkillField(idx, 'required_level', e.target.value)}
>
{LEVEL_OPTIONS.map((o) => (
<option key={`r-${o.value}`} value={o.value}>
von {o.label}
</option>
))}
</select>
<select
className="form-input"
value={row.target_level || ''}
onChange={(e) => updateSkillField(idx, 'target_level', e.target.value)}
>
{LEVEL_OPTIONS.map((o) => (
<option key={`t-${o.value}`} value={o.value}>
bis {o.label}
</option>
))}
</select>
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => removeSkillRow(idx)}>
Entf.
</button>
</div>
)
})}
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div className="form-row"> <div className="form-row">
<label className="form-label">Sichtbarkeit</label> <label className="form-label">Sichtbarkeit</label>
<select <select
@ -488,94 +710,7 @@ function ExerciseFormPage() {
</div> </div>
</div> </div>
<div className="form-row"> <div style={{ marginTop: '16px' }}>
<label className="form-label">Fähigkeiten</label>
<div
style={{
maxHeight: '220px',
overflowY: 'auto',
border: '1px solid var(--border)',
borderRadius: '8px',
padding: '0.5rem',
}}
>
{skillsCatalog.map((skill) => {
const sel = formData.skills.find((s) => s.skill_id === skill.id)
return (
<div
key={skill.id}
style={{
padding: '0.5rem',
borderBottom: '1px solid var(--border)',
}}
>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input
type="checkbox"
checked={!!sel}
onChange={() => toggleSkill(skill.id)}
/>
<span style={{ fontSize: '0.875rem' }}>
{skill.name}{' '}
<span style={{ color: 'var(--text2)' }}>({skill.category})</span>
</span>
</label>
{sel && (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
gap: '0.5rem',
marginTop: '0.5rem',
marginLeft: '1.5rem',
}}
>
<select
className="form-input"
value={sel.intensity || ''}
onChange={(e) => updateSkillField(skill.id, 'intensity', e.target.value)}
>
{INTENSITY_OPTIONS.map((o) => (
<option key={o.value || 'x'} value={o.value}>
{o.label}
</option>
))}
</select>
<select
className="form-input"
value={sel.required_level || ''}
onChange={(e) =>
updateSkillField(skill.id, 'required_level', e.target.value)
}
>
{LEVEL_OPTIONS.map((o) => (
<option key={o.value || 'r'} value={o.value}>
von {o.label}
</option>
))}
</select>
<select
className="form-input"
value={sel.target_level || ''}
onChange={(e) =>
updateSkillField(skill.id, 'target_level', e.target.value)
}
>
{LEVEL_OPTIONS.map((o) => (
<option key={o.value || 't'} value={o.value}>
bis {o.label}
</option>
))}
</select>
</div>
)}
</div>
)
})}
</div>
</div>
<div style={{ marginTop: '1.5rem' }}>
<button type="submit" className="btn btn-primary" disabled={saving}> <button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Speichern…' : isEdit ? 'Speichern' : 'Anlegen & weiter'} {saving ? 'Speichern…' : isEdit ? 'Speichern' : 'Anlegen & weiter'}
</button> </button>
@ -584,30 +719,24 @@ function ExerciseFormPage() {
</div> </div>
{isEdit && ( {isEdit && (
<div className="card" style={{ marginTop: '1.5rem' }}> <div className="card" style={{ marginTop: '16px' }}>
<h2 style={{ marginTop: 0 }}>Medien</h2> <h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Medien</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}> <p style={{ color: 'var(--text2)', fontSize: '13px' }}>
Datei (JPEG/PNG/GIF/MP4/PDF) oder Embed-URL (YouTube, Vimeo, Instagram, TikTok). Max. 10 Datei oder Embed (YouTube, Vimeo, Instagram, TikTok). Max. 10 pro Übung.
Medien pro Übung.
</p> </p>
<div style={{ display: 'grid', gap: '12px', marginTop: '12px' }}>
<div style={{ display: 'grid', gap: '1rem', marginTop: '1rem' }}>
<div> <div>
<label className="form-label">Datei hochladen</label> <label className="form-label">Datei</label>
<input <input
type="file" type="file"
accept="image/jpeg,image/png,image/gif,video/mp4,application/pdf" accept="image/jpeg,image/png,image/gif,video/mp4,application/pdf"
onChange={(e) => setMediaFile(e.target.files?.[0] || null)} onChange={(e) => setMediaFile(e.target.files?.[0] || null)}
/> />
<div className="form-row" style={{ marginTop: '0.5rem' }}> <div className="form-row" style={{ marginTop: '8px' }}>
<select <select className="form-input" value={mediaType} onChange={(e) => setMediaType(e.target.value)}>
className="form-input"
value={mediaType}
onChange={(e) => setMediaType(e.target.value)}
>
<option value="image">Bild</option> <option value="image">Bild</option>
<option value="video">Video</option> <option value="video">Video</option>
<option value="document">Dokument (PDF)</option> <option value="document">PDF</option>
</select> </select>
<input <input
type="text" type="text"
@ -615,26 +744,25 @@ function ExerciseFormPage() {
placeholder="Titel (optional)" placeholder="Titel (optional)"
value={mediaTitle} value={mediaTitle}
onChange={(e) => setMediaTitle(e.target.value)} onChange={(e) => setMediaTitle(e.target.value)}
style={{ marginTop: '0.5rem' }} style={{ marginTop: '8px' }}
/> />
<select <select
className="form-input" className="form-input"
value={mediaContext} value={mediaContext}
onChange={(e) => setMediaContext(e.target.value)} onChange={(e) => setMediaContext(e.target.value)}
style={{ marginTop: '0.5rem' }} style={{ marginTop: '8px' }}
> >
<option value="ablauf">Ablauf</option> <option value="ablauf">Ablauf</option>
<option value="detail">Detail</option> <option value="detail">Detail</option>
<option value="trainer_hint">Trainer-Hinweis</option> <option value="trainer_hint">Trainer-Hinweis</option>
</select> </select>
</div> </div>
<button type="button" className="btn btn-secondary" onClick={handleUploadFile}> <button type="button" className="btn btn-secondary" style={{ marginTop: '8px' }} onClick={handleUploadFile}>
Hochladen Hochladen
</button> </button>
</div> </div>
<div> <div>
<label className="form-label">Video / Embed-URL</label> <label className="form-label">Embed-URL</label>
<input <input
type="url" type="url"
className="form-input" className="form-input"
@ -648,32 +776,26 @@ function ExerciseFormPage() {
placeholder="Titel (optional)" placeholder="Titel (optional)"
value={embedTitle} value={embedTitle}
onChange={(e) => setEmbedTitle(e.target.value)} onChange={(e) => setEmbedTitle(e.target.value)}
style={{ marginTop: '0.5rem' }} style={{ marginTop: '8px' }}
/> />
<button <button type="button" className="btn btn-secondary" style={{ marginTop: '8px' }} onClick={handleAddEmbed}>
type="button"
className="btn btn-secondary"
style={{ marginTop: '0.5rem' }}
onClick={handleAddEmbed}
>
Embed hinzufügen Embed hinzufügen
</button> </button>
</div> </div>
</div> </div>
{mediaList.length > 0 && ( {mediaList.length > 0 && (
<ul style={{ marginTop: '1rem', paddingLeft: '1.25rem' }}> <ul style={{ marginTop: '12px', paddingLeft: '1.2rem' }}>
{mediaList.map((m) => ( {mediaList.map((m) => (
<li key={m.id} style={{ marginBottom: '0.5rem' }}> <li key={m.id} style={{ marginBottom: '6px' }}>
{m.title || m.original_filename || m.media_type}{' '} {m.title || m.original_filename || m.media_type}{' '}
{m.embed_platform ? `(${m.embed_platform})` : ''} {m.embed_platform ? `(${m.embed_platform})` : ''}
<button <button
type="button" type="button"
className="btn" className="btn"
style={{ style={{
marginLeft: '0.5rem', marginLeft: '8px',
fontSize: '0.75rem', fontSize: '11px',
padding: '0.2rem 0.5rem', padding: '2px 8px',
background: 'var(--danger)', background: 'var(--danger)',
color: '#fff', color: '#fff',
border: 'none', border: 'none',
@ -688,7 +810,10 @@ function ExerciseFormPage() {
)} )}
</div> </div>
)} )}
</div>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}>
Varianten-Editor folgt in einem späteren Schritt (API ist teilweise vorhanden).
</p>
</div> </div>
) )
} }

View File

@ -2,10 +2,15 @@ import React, { useState, useEffect } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
const PAGE_SIZE = 100
function ExercisesListPage() { function ExercisesListPage() {
const [exercises, setExercises] = useState([]) const [exercises, setExercises] = useState([])
const [focusAreas, setFocusAreas] = useState([]) const [focusAreas, setFocusAreas] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(false)
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
focus_area: '', focus_area: '',
visibility: '', visibility: '',
@ -13,22 +18,47 @@ function ExercisesListPage() {
}) })
useEffect(() => { useEffect(() => {
loadData() let cancelled = false
}, [filters]) const run = async () => {
setLoading(true)
const loadData = async () => { setOffset(0)
try { try {
const [exercisesData, focusAreasData] = await Promise.all([ const [batch, focusAreasData] = await Promise.all([
api.listExercises(filters), api.listExercises({ ...filters, limit: PAGE_SIZE, offset: 0 }),
api.listFocusAreas(), api.listFocusAreas(),
]) ])
setExercises(exercisesData) if (cancelled) return
setExercises(batch)
setFocusAreas(focusAreasData) setFocusAreas(focusAreasData)
setHasMore(batch.length === PAGE_SIZE)
setOffset(batch.length)
} catch (err) { } catch (err) {
if (!cancelled) {
console.error('Failed to load data:', err) console.error('Failed to load data:', err)
alert('Fehler beim Laden: ' + err.message) alert('Fehler beim Laden: ' + err.message)
}
} finally { } finally {
setLoading(false) if (!cancelled) setLoading(false)
}
}
run()
return () => {
cancelled = true
}
}, [filters])
const loadMore = async () => {
if (loadingMore || !hasMore) return
setLoadingMore(true)
try {
const batch = await api.listExercises({ ...filters, limit: PAGE_SIZE, offset })
setExercises((prev) => [...prev, ...batch])
setHasMore(batch.length === PAGE_SIZE)
setOffset((o) => o + batch.length)
} catch (err) {
alert('Fehler: ' + err.message)
} finally {
setLoadingMore(false)
} }
} }
@ -36,7 +66,7 @@ function ExercisesListPage() {
if (!confirm(`Übung "${exercise.title}" wirklich löschen?`)) return if (!confirm(`Übung "${exercise.title}" wirklich löschen?`)) return
try { try {
await api.deleteExercise(exercise.id) await api.deleteExercise(exercise.id)
await loadData() setExercises((prev) => prev.filter((e) => e.id !== exercise.id))
} catch (err) { } catch (err) {
alert('Fehler beim Löschen: ' + err.message) alert('Fehler beim Löschen: ' + err.message)
} }
@ -52,34 +82,26 @@ function ExercisesListPage() {
} }
return ( return (
<div style={{ padding: '2rem' }}> <div style={{ padding: '12px', maxWidth: '1200px', margin: '0 auto' }}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
<div <div
style={{ style={{
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
marginBottom: '1.5rem', marginBottom: '12px',
flexWrap: 'wrap', flexWrap: 'wrap',
gap: '0.75rem', gap: '8px',
}} }}
> >
<h1>Übungen</h1> <h1 style={{ fontSize: '1.35rem' }}>Übungen</h1>
<Link to="/exercises/new" className="btn btn-primary"> <Link to="/exercises/new" className="btn btn-primary">
+ Neue Übung + Neu
</Link> </Link>
</div> </div>
<div className="card" style={{ marginBottom: '1.5rem' }}> <div className="card exercise-filters-compact" style={{ marginBottom: '12px' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '1rem',
}}
>
<div> <div>
<label className="form-label">Fokusbereich</label> <label className="form-label">Fokus</label>
<select <select
className="form-input" className="form-input"
value={filters.focus_area} value={filters.focus_area}
@ -121,26 +143,30 @@ function ExercisesListPage() {
</select> </select>
</div> </div>
</div> </div>
</div>
{exercises.length === 0 ? ( {exercises.length === 0 ? (
<div className="card"> <div className="card">
<p style={{ color: 'var(--text2)', textAlign: 'center' }}> <p style={{ color: 'var(--text2)', textAlign: 'center' }}>
Keine Übungen gefunden. Lege jetzt deine erste Übung an! Keine Übungen gefunden.
</p> </p>
</div> </div>
) : ( ) : (
<>
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '10px' }}>
{exercises.length} angezeigt
{hasMore ? ' · es gibt weitere Einträge' : ''}
</p>
<div <div
style={{ style={{
display: 'grid', display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '1rem', gap: '12px',
}} }}
> >
{exercises.map((exercise) => ( {exercises.map((exercise) => (
<div key={exercise.id} className="card"> <div key={exercise.id} className="card exercise-card">
<div style={{ marginBottom: '1rem' }}> <div className="exercise-card__body">
<h3 style={{ marginBottom: '0.5rem' }}> <h3 style={{ marginBottom: '8px', fontSize: '1.05rem', lineHeight: 1.3 }}>
<Link <Link
to={`/exercises/${exercise.id}`} to={`/exercises/${exercise.id}`}
style={{ color: 'inherit', textDecoration: 'none' }} style={{ color: 'inherit', textDecoration: 'none' }}
@ -148,68 +174,32 @@ function ExercisesListPage() {
{exercise.title} {exercise.title}
</Link> </Link>
</h3> </h3>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', marginBottom: '8px' }}>
<span {exercise.focus_area && (
style={{ <span className="exercise-tag exercise-tag--accent">{exercise.focus_area}</span>
fontSize: '0.75rem', )}
padding: '0.25rem 0.5rem', <span className="exercise-tag">{exercise.visibility}</span>
borderRadius: '4px', <span className="exercise-tag">{exercise.status}</span>
background: 'var(--surface2)',
color: 'var(--text2)',
}}
>
{exercise.focus_area || 'Ohne Fokus'}
</span>
<span
style={{
fontSize: '0.75rem',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
background:
exercise.visibility === 'official' ? 'var(--accent)' : 'var(--surface2)',
color: exercise.visibility === 'official' ? 'white' : 'var(--text2)',
}}
>
{exercise.visibility}
</span>
<span
style={{
fontSize: '0.75rem',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
background: exercise.status === 'approved' ? '#2ea44f' : 'var(--surface2)',
color: exercise.status === 'approved' ? 'white' : 'var(--text2)',
}}
>
{exercise.status}
</span>
</div>
</div> </div>
{exercise.summary && ( {exercise.summary && (
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem' }}> <p style={{ color: 'var(--text2)', fontSize: '13px', lineHeight: 1.4 }}>
{exercise.summary} {exercise.summary.length > 160
? `${exercise.summary.slice(0, 160)}`
: exercise.summary}
</p> </p>
)} )}
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto', flexWrap: 'wrap' }}> </div>
<Link <div className="exercise-card__actions">
to={`/exercises/${exercise.id}`} <Link to={`/exercises/${exercise.id}`} className="btn btn-secondary">
className="btn btn-secondary"
style={{ flex: '1 1 100px', textAlign: 'center' }}
>
Ansehen Ansehen
</Link> </Link>
<Link <Link to={`/exercises/${exercise.id}/edit`} className="btn btn-secondary">
to={`/exercises/${exercise.id}/edit`}
className="btn btn-secondary"
style={{ flex: '1 1 100px', textAlign: 'center' }}
>
Bearbeiten Bearbeiten
</Link> </Link>
<button <button
type="button" type="button"
className="btn" className="btn"
style={{ style={{
flex: '1 1 100px',
background: 'var(--danger)', background: 'var(--danger)',
color: 'white', color: 'white',
border: 'none', border: 'none',
@ -222,8 +212,15 @@ function ExercisesListPage() {
</div> </div>
))} ))}
</div> </div>
)} {hasMore && (
<div style={{ textAlign: 'center', marginTop: '16px' }}>
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
{loadingMore ? 'Laden…' : 'Mehr laden'}
</button>
</div> </div>
)}
</>
)}
</div> </div>
) )
} }

View File

@ -4,6 +4,8 @@
* Zentrale API-Kommunikation mit automatischer Token-Injektion * Zentrale API-Kommunikation mit automatischer Token-Injektion
*/ */
import { stripHtmlToText } from './htmlUtils'
const API_URL = import.meta.env.VITE_API_URL || '' const API_URL = import.meta.env.VITE_API_URL || ''
/** /**
@ -219,19 +221,37 @@ export async function listExercises(filters = {}) {
return request(`/api/exercises${query ? '?' + query : ''}`) return request(`/api/exercises${query ? '?' + query : ''}`)
} }
/** Formular → API-Body (M:N gemäß EXERCISES_API_SPEC) */ /** Formular → API-Body (M:N gemäß EXERCISES_API_SPEC + training_types) */
export function buildExerciseApiPayload(formData) { export function buildExerciseApiPayload(formData) {
const num = (v) => (v === '' || v == null ? null : Number(v)) const num = (v) => (v === '' || v == null ? null : Number(v))
const goal = (formData.goal || '').trim()
const execution = (formData.execution || '').trim() const goalHtml = formData.goal || ''
if (!goal && !execution) { const execHtml = formData.execution || ''
throw new Error('Ziel oder Durchführung ausfüllen (mindestens eines).') const goalText = stripHtmlToText(goalHtml)
const execText = stripHtmlToText(execHtml)
if (!goalText && !execText) {
throw new Error('Ziel oder Durchführung ausfüllen (mindestens eines, auch mit Formatierung).')
} }
const mapFocus = (formData.focus_areas_multi || [])
.filter((x) => x && x.focus_area_id)
.map((x) => ({ focus_area_id: Number(x.focus_area_id), is_primary: !!x.is_primary }))
const mapStyles = (formData.training_styles_multi || [])
.filter((x) => x && x.training_style_id)
.map((x) => ({ training_style_id: Number(x.training_style_id), is_primary: !!x.is_primary }))
const mapTTypes = (formData.training_types_multi || [])
.filter((x) => x && x.training_type_id)
.map((x) => ({ training_type_id: Number(x.training_type_id), is_primary: !!x.is_primary }))
const mapTg = (formData.target_groups_multi || [])
.filter((x) => x && x.target_group_id)
.map((x) => ({ target_group_id: Number(x.target_group_id), is_primary: !!x.is_primary }))
return { return {
title: (formData.title || '').trim(), title: (formData.title || '').trim(),
summary: formData.summary || null, summary: formData.summary || null,
goal: goal || null, goal: goalHtml.trim() ? goalHtml : null,
execution: execution || null, execution: execHtml.trim() ? execHtml : null,
preparation: formData.preparation || null, preparation: formData.preparation || null,
trainer_notes: formData.trainer_notes || null, trainer_notes: formData.trainer_notes || null,
duration_min: num(formData.duration_min), duration_min: num(formData.duration_min),
@ -239,13 +259,10 @@ export function buildExerciseApiPayload(formData) {
group_size_min: num(formData.group_size_min), group_size_min: num(formData.group_size_min),
group_size_max: num(formData.group_size_max), group_size_max: num(formData.group_size_max),
equipment: Array.isArray(formData.equipment) ? formData.equipment : [], equipment: Array.isArray(formData.equipment) ? formData.equipment : [],
focus_areas_multi: formData.focus_area_id focus_areas_multi: mapFocus,
? [{ focus_area_id: formData.focus_area_id, is_primary: true }] training_styles_multi: mapStyles,
: [], training_types_multi: mapTTypes,
training_styles_multi: formData.training_style_id target_groups_multi: mapTg,
? [{ training_style_id: formData.training_style_id, is_primary: true }]
: [],
target_groups_multi: [],
age_groups: formData.age_groups || [], age_groups: formData.age_groups || [],
skills: (formData.skills || []).map((s) => ({ skills: (formData.skills || []).map((s) => ({
skill_id: s.skill_id, skill_id: s.skill_id,

View File

@ -0,0 +1,23 @@
/** Einfache HTML-Hilfen für Rich-Text (Trainer-Content, kein öffentliches CMS). */
export function stripHtmlToText(html) {
if (!html || typeof html !== 'string') return ''
const d = document.createElement('div')
d.innerHTML = html
return (d.textContent || '').replace(/\s+/g, ' ').trim()
}
/** Entfernt script/iframes und Event-Handler-Attribute grob. */
export function sanitizeTrainerHtml(html) {
if (!html || typeof html !== 'string') return ''
const d = document.createElement('div')
d.innerHTML = html
d.querySelectorAll('script, iframe, object, embed').forEach((n) => n.remove())
d.querySelectorAll('*').forEach((el) => {
for (const attr of [...el.attributes]) {
const n = attr.name.toLowerCase()
if (n.startsWith('on') || n === 'srcdoc') el.removeAttribute(attr.name)
}
})
return d.innerHTML
}