shinkan-jinkendo/frontend/src/pages/ExerciseFormPage.jsx
Lars 8c9c97bedb
Some checks failed
Deploy Development / deploy (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 40s
refactor: simplify page layout for improved responsiveness
- Removed constrained width classes from multiple pages to allow full-width layout, enhancing adaptability on larger screens.
- Updated app.css to eliminate unnecessary max-width properties, ensuring a more fluid design across various components.
- Adjusted styles in Navigation and other pages for consistent full-width presentation, improving user experience on diverse devices.
2026-05-05 12:41:59 +02:00

1272 lines
45 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState, useRef } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import api, { buildExerciseApiPayload } from '../utils/api'
import RichTextEditor from '../components/RichTextEditor'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
const INTENSITY_OPTIONS = [
{ value: '', label: '—' },
{ value: 'niedrig', label: 'niedrig' },
{ value: 'mittel', label: 'mittel' },
{ value: 'hoch', label: 'hoch' },
]
const VARIANT_DIFFICULTY = [
{ value: '', label: '—' },
{ value: 'easier', label: 'Einfacher' },
{ value: 'same', label: 'Gleich' },
{ value: 'harder', label: 'Schwerer' },
{ value: 'adapted', label: 'Angepasst' },
]
function emptyVariantDraft() {
return {
variant_name: '',
description: '',
execution_changes: '',
duration_min: '',
duration_max: '',
equipment_lines: '',
difficulty_adjustment: '',
progression_level: 1,
prerequisite_variant_id: '',
}
}
function apiVariantToRow(v) {
let lines = ''
const eq = v.equipment_changes
if (Array.isArray(eq)) {
lines = eq.join('\n')
} else if (typeof eq === 'string' && eq.trim()) {
try {
const p = JSON.parse(eq)
lines = Array.isArray(p) ? p.join('\n') : eq
} catch {
lines = eq
}
}
return {
...v,
duration_min: v.duration_min ?? '',
duration_max: v.duration_max ?? '',
equipment_lines: lines,
progression_level: v.progression_level ?? 1,
prerequisite_variant_id: v.prerequisite_variant_id ?? '',
difficulty_adjustment: v.difficulty_adjustment ?? '',
}
}
function buildVariantPayloadFromRow(row) {
const lines = (row.equipment_lines || '')
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean)
const pl =
row.progression_level === '' || row.progression_level == null
? 1
: parseInt(row.progression_level, 10)
const so =
row.sequence_order === '' || row.sequence_order == null
? null
: parseInt(row.sequence_order, 10)
return {
variant_name: (row.variant_name || '').trim(),
description: (row.description || '').trim() || null,
execution_changes: (row.execution_changes || '').trim() || null,
duration_min: row.duration_min === '' || row.duration_min == null ? null : parseInt(row.duration_min, 10),
duration_max: row.duration_max === '' || row.duration_max == null ? null : parseInt(row.duration_max, 10),
equipment_changes: lines,
difficulty_adjustment: row.difficulty_adjustment || null,
progression_level: Number.isNaN(pl) ? 1 : pl,
sequence_order: so !== null && Number.isNaN(so) ? null : so,
prerequisite_variant_id:
row.prerequisite_variant_id === '' || row.prerequisite_variant_id == null
? null
: parseInt(row.prerequisite_variant_id, 10),
}
}
/** Gemeinsame Felder für „Variante bearbeiten“ und „Neue Variante“. */
function ExerciseVariantFields({ row, onPatch, prerequisiteOthers, rteMinHeight = '110px' }) {
return (
<>
<div className="form-row">
<label className="form-label">Variantenname *</label>
<input
type="text"
className="form-input"
value={row.variant_name || ''}
onChange={(e) => onPatch({ variant_name: e.target.value })}
minLength={3}
/>
</div>
<div className="form-row">
<label className="form-label">Kurzbeschreibung</label>
<textarea
className="form-input"
rows={2}
value={row.description || ''}
onChange={(e) => onPatch({ description: e.target.value })}
/>
</div>
<div className="form-row">
<label className="form-label">Abweichungen zur Durchführung</label>
<RichTextEditor
value={row.execution_changes || ''}
onChange={(html) => onPatch({ execution_changes: html })}
placeholder="Was unterscheidet diese Variante? (Listen über Symbolleiste)"
minHeight={rteMinHeight}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div className="form-row">
<label className="form-label">Dauer Min</label>
<input
type="number"
className="form-input"
value={row.duration_min}
onChange={(e) => onPatch({ duration_min: e.target.value })}
/>
</div>
<div className="form-row">
<label className="form-label">Dauer Max</label>
<input
type="number"
className="form-input"
value={row.duration_max}
onChange={(e) => onPatch({ duration_max: e.target.value })}
/>
</div>
</div>
<div className="form-row">
<label className="form-label">Materialänderungen (eine Zeile pro Eintrag)</label>
<textarea
className="form-input"
rows={2}
value={row.equipment_lines || ''}
onChange={(e) => onPatch({ equipment_lines: e.target.value })}
placeholder="+ Pratzen"
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: '10px' }}>
<div className="form-row">
<label className="form-label">Schwere relativ</label>
<select
className="form-input"
value={row.difficulty_adjustment || ''}
onChange={(e) => onPatch({ difficulty_adjustment: e.target.value })}
>
{VARIANT_DIFFICULTY.map((o) => (
<option key={o.label} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div className="form-row">
<label className="form-label">Progressions-Stufe (110)</label>
<input
type="number"
min={1}
max={10}
className="form-input"
value={row.progression_level}
onChange={(e) =>
onPatch({
progression_level: e.target.value === '' ? '' : parseInt(e.target.value, 10),
})
}
/>
</div>
<div className="form-row">
<label className="form-label">Voraussetzungs-Variante</label>
<select
className="form-input"
value={
row.prerequisite_variant_id === '' || row.prerequisite_variant_id == null
? ''
: String(row.prerequisite_variant_id)
}
onChange={(e) =>
onPatch({
prerequisite_variant_id: e.target.value === '' ? '' : parseInt(e.target.value, 10),
})
}
>
<option value=""> keine </option>
{prerequisiteOthers.map((o) => (
<option key={o.id} value={o.id}>
{o.variant_name || `Variante #${o.id}`}
</option>
))}
</select>
</div>
</div>
</>
)
}
function emptyForm() {
return {
title: '',
summary: '',
goal: '',
execution: '',
preparation: '',
trainer_notes: '',
equipmentLines: '',
duration_min: '',
duration_max: '',
group_size_min: '',
group_size_max: '',
focus_areas_multi: [],
training_styles_multi: [],
training_types_multi: [],
target_groups_multi: [],
visibility: 'private',
status: 'draft',
skills: [],
}
}
function detailToForm(exercise) {
return {
title: exercise.title || '',
summary: exercise.summary || '',
goal: exercise.goal || '',
execution: exercise.execution || '',
preparation: exercise.preparation || '',
trainer_notes: exercise.trainer_notes || '',
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 ?? '',
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:
exercise.skills?.map((s) => ({
skill_id: s.skill_id,
is_primary: s.is_primary || false,
intensity: s.intensity || '',
required_level: normalizeSkillLevelSlug(s.required_level),
target_level: normalizeSkillLevelSlug(s.target_level),
})) || [],
}
}
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() {
const { id: routeId } = useParams()
const navigate = useNavigate()
const exerciseId = routeId && !Number.isNaN(parseInt(routeId, 10)) ? parseInt(routeId, 10) : null
const isEdit = exerciseId != null
const [formData, setFormData] = useState(emptyForm)
const [skillsCatalog, setSkillsCatalog] = useState([])
const [focusAreas, setFocusAreas] = 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 [variants, setVariants] = useState([])
const [variantDraft, setVariantDraft] = useState(() => emptyVariantDraft())
const [variantSavingId, setVariantSavingId] = useState(null)
const [variantBusy, setVariantBusy] = useState(false)
const [variantEditSelection, setVariantEditSelection] = useState(null)
const variantsDetailsRef = useRef(null)
const [mediaFile, setMediaFile] = useState(null)
const [mediaType, setMediaType] = useState('image')
const [mediaTitle, setMediaTitle] = useState('')
const [mediaContext, setMediaContext] = useState('ablauf')
const [embedUrl, setEmbedUrl] = useState('')
const [embedTitle, setEmbedTitle] = useState('')
useEffect(() => {
let cancelled = false
const boot = async () => {
try {
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)
setStyleDirections(sdData)
setTrainingTypes(ttData)
setTargetGroups(tgData)
} catch (e) {
if (!cancelled) {
console.error(e)
alert(
'Kataloge (Fokus, Stile, Zielgruppen, Fähigkeiten) konnten nicht geladen werden: ' +
(e.message || e),
)
}
}
}
boot()
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (!isEdit) {
setFormData(emptyForm())
setMediaList([])
setVariants([])
setVariantDraft(emptyVariantDraft())
setVariantEditSelection(null)
setLoading(false)
return
}
let cancelled = false
const load = async () => {
setLoading(true)
try {
const exercise = await api.getExercise(exerciseId)
if (cancelled) return
setFormData(detailToForm(exercise))
setMediaList(exercise.media || [])
setVariants((exercise.variants || []).map(apiVariantToRow))
setVariantDraft(emptyVariantDraft())
setVariantEditSelection(null)
} catch (err) {
if (!cancelled) {
alert(err.message || 'Übung nicht ladbar')
navigate('/exercises')
}
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => {
cancelled = true
}
}, [isEdit, exerciseId, navigate])
useEffect(() => {
if (variantEditSelection == null || variantEditSelection === 'new') return
if (!variants.some((v) => v.id === variantEditSelection)) {
setVariantEditSelection(null)
}
}, [variants, variantEditSelection])
useEffect(() => {
if (variantEditSelection != null && variantsDetailsRef.current) {
variantsDetailsRef.current.open = true
}
}, [variantEditSelection])
const updateFormField = (field, value) => {
setFormData((prev) => ({ ...prev, [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, 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(payloadBase)
} catch (err) {
alert(err.message)
return
}
setSaving(true)
try {
if (isEdit) {
await api.updateExercise(exerciseId, payload)
const ex = await api.getExercise(exerciseId)
setMediaList(ex.media || [])
setVariants((ex.variants || []).map(apiVariantToRow))
alert('Gespeichert.')
} else {
const created = await api.createExercise(payload)
navigate(`/exercises/${created.id}/edit`, { replace: true })
}
} catch (err) {
alert('Fehler beim Speichern: ' + err.message)
} finally {
setSaving(false)
}
}
const refreshMedia = async () => {
if (!exerciseId) return
const ex = await api.getExercise(exerciseId)
setMediaList(ex.media || [])
}
const handleUploadFile = async () => {
if (!exerciseId || !mediaFile) {
alert('Datei wählen')
return
}
const fd = new FormData()
fd.append('file', mediaFile)
fd.append('media_type', mediaType)
fd.append('title', mediaTitle)
fd.append('description', '')
fd.append('context', mediaContext)
fd.append('is_primary', 'false')
try {
await api.uploadExerciseMedia(exerciseId, fd)
setMediaFile(null)
setMediaTitle('')
await refreshMedia()
} catch (err) {
alert('Upload: ' + err.message)
}
}
const handleAddEmbed = async () => {
if (!exerciseId || !embedUrl.trim()) {
alert('Embed-URL eingeben')
return
}
const fd = new FormData()
fd.append('embed_url', embedUrl.trim())
fd.append('media_type', 'video')
fd.append('title', embedTitle)
fd.append('description', '')
fd.append('context', mediaContext)
fd.append('is_primary', 'false')
try {
await api.uploadExerciseMedia(exerciseId, fd)
setEmbedUrl('')
setEmbedTitle('')
await refreshMedia()
} catch (err) {
alert('Embed: ' + err.message)
}
}
const handleDeleteMedia = async (mid) => {
if (!confirm('Medium löschen?')) return
try {
await api.deleteExerciseMedia(exerciseId, mid)
await refreshMedia()
} catch (err) {
alert(err.message)
}
}
const refreshVariants = async () => {
if (!exerciseId) return
const ex = await api.getExercise(exerciseId)
setVariants((ex.variants || []).map(apiVariantToRow))
}
const updateVariantField = (id, patch) => {
setVariants((prev) => prev.map((v) => (v.id === id ? { ...v, ...patch } : v)))
}
const saveVariantRow = async (row) => {
const payload = buildVariantPayloadFromRow(row)
if (payload.variant_name.length < 3) {
alert('Variantenname mindestens 3 Zeichen')
return
}
setVariantSavingId(row.id)
try {
await api.updateExerciseVariant(exerciseId, row.id, payload)
await refreshVariants()
} catch (e) {
alert(e.message || String(e))
} finally {
setVariantSavingId(null)
}
}
const deleteVariantRow = async (id) => {
if (!confirm('Variante wirklich löschen?')) return
setVariantBusy(true)
try {
await api.deleteExerciseVariant(exerciseId, id)
if (variantEditSelection === id) setVariantEditSelection(null)
await refreshVariants()
} catch (e) {
alert(e.message || String(e))
} finally {
setVariantBusy(false)
}
}
const moveVariantRow = async (idx, dir) => {
const j = idx + dir
if (j < 0 || j >= variants.length) return
const next = [...variants]
const tmp = next[idx]
next[idx] = next[j]
next[j] = tmp
const ids = next.map((x) => x.id)
setVariantBusy(true)
try {
await api.reorderExerciseVariants(exerciseId, ids)
await refreshVariants()
} catch (e) {
alert(e.message || String(e))
} finally {
setVariantBusy(false)
}
}
const createVariantSubmit = async (e) => {
e.preventDefault()
if (!exerciseId) return
const payload = buildVariantPayloadFromRow(variantDraft)
if (payload.variant_name.length < 3) {
alert('Variantenname mindestens 3 Zeichen')
return
}
setVariantBusy(true)
try {
const created = await api.createExerciseVariant(exerciseId, payload)
setVariantDraft(emptyVariantDraft())
await refreshVariants()
if (created?.id != null) setVariantEditSelection(created.id)
else setVariantEditSelection(null)
} catch (err) {
alert(err.message || String(err))
} finally {
setVariantBusy(false)
}
}
const availableSkills = skillsCatalog.filter((s) => !formData.skills.some((x) => x.skill_id === s.id))
const selectedVariantForEdit =
typeof variantEditSelection === 'number' ? variants.find((v) => v.id === variantEditSelection) : null
const selectedVariantIdx = selectedVariantForEdit
? variants.findIndex((v) => v.id === selectedVariantForEdit.id)
: -1
if (loading) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="spinner"></div>
<p>Laden...</p>
</div>
)
}
return (
<div style={{ padding: '12px' }} className="app-page">
<div style={{ marginBottom: '12px' }}>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}>
Übersicht
</button>
{isEdit && (
<button
type="button"
className="btn btn-secondary"
style={{ marginLeft: '8px' }}
onClick={() => navigate(`/exercises/${exerciseId}`)}
>
Ansehen
</button>
)}
</div>
<div className="card">
<h1 style={{ marginTop: 0, fontSize: '1.25rem' }}>{isEdit ? 'Übung bearbeiten' : 'Neue Übung'}</h1>
<form onSubmit={handleSubmit}>
<div className="form-row">
<label className="form-label">Titel *</label>
<input
type="text"
className="form-input"
value={formData.title}
onChange={(e) => updateFormField('title', e.target.value)}
required
minLength={3}
/>
</div>
<div className="form-row">
<label className="form-label">Kurzbeschreibung</label>
<RichTextEditor
value={formData.summary}
onChange={(html) => updateFormField('summary', html)}
placeholder="Kurzbeschreibung (optional)"
minHeight="80px"
/>
</div>
<div className="form-row">
<label className="form-label">Ziel *</label>
<RichTextEditor
value={formData.goal}
onChange={(html) => updateFormField('goal', html)}
placeholder="Trainingsziel"
minHeight="120px"
/>
</div>
<div className="form-row">
<label className="form-label">Durchführung *</label>
<RichTextEditor
value={formData.execution}
onChange={(html) => updateFormField('execution', html)}
placeholder="Ablauf Schritt für Schritt"
minHeight="180px"
/>
</div>
<div className="form-row">
<label className="form-label">Vorbereitung / Aufbau</label>
<RichTextEditor
value={formData.preparation}
onChange={(html) => updateFormField('preparation', html)}
placeholder="Matten, Raum, …"
minHeight="100px"
/>
</div>
<div className="form-row">
<label className="form-label">Hinweise für Trainer</label>
<RichTextEditor
value={formData.trainer_notes}
onChange={(html) => updateFormField('trainer_notes', html)}
placeholder="Sicherheit, Varianten-Hinweise, …"
minHeight="100px"
/>
</div>
<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">
<label className="form-label">Dauer Min</label>
<input
type="number"
className="form-input"
value={formData.duration_min}
onChange={(e) =>
updateFormField('duration_min', e.target.value ? parseInt(e.target.value, 10) : '')
}
/>
</div>
<div className="form-row">
<label className="form-label">Dauer Max</label>
<input
type="number"
className="form-input"
value={formData.duration_max}
onChange={(e) =>
updateFormField('duration_max', e.target.value ? parseInt(e.target.value, 10) : '')
}
/>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div className="form-row">
<label className="form-label">Gruppe Min</label>
<input
type="number"
className="form-input"
value={formData.group_size_min}
onChange={(e) =>
updateFormField('group_size_min', e.target.value ? parseInt(e.target.value, 10) : '')
}
/>
</div>
<div className="form-row">
<label className="form-label">Gruppe Max</label>
<input
type="number"
className="form-input"
value={formData.group_size_max}
onChange={(e) =>
updateFormField('group_size_max', e.target.value ? parseInt(e.target.value, 10) : '')
}
/>
</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">
<label className="form-label">Fähigkeiten (je Übung mehrere, mit Niveau)</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '10px' }}>
<select
className="form-input"
style={{ flex: '1 1 200px' }}
value={skillPick}
onChange={(e) => setSkillPick(e.target.value)}
>
<option value="">Fähigkeit wählen</option>
{availableSkills.map((s) => (
<option key={s.id} value={s.id}>
{s.name} ({s.category})
</option>
))}
</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)}
>
{SKILL_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)}
>
{SKILL_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 style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div className="form-row">
<label className="form-label">Sichtbarkeit</label>
<select
className="form-input"
value={formData.visibility}
onChange={(e) => updateFormField('visibility', e.target.value)}
>
<option value="private">Privat</option>
<option value="club">Verein</option>
<option value="official">Offiziell</option>
</select>
</div>
<div className="form-row">
<label className="form-label">Status</label>
<select
className="form-input"
value={formData.status}
onChange={(e) => updateFormField('status', e.target.value)}
>
<option value="draft">Entwurf</option>
<option value="in_review">In Prüfung</option>
<option value="approved">Freigegeben</option>
<option value="archived">Archiviert</option>
</select>
</div>
</div>
<div style={{ marginTop: '16px' }}>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Speichern…' : isEdit ? 'Speichern' : 'Anlegen & weiter'}
</button>
</div>
</form>
</div>
{isEdit && (
<details ref={variantsDetailsRef} className="card exercise-variants-details" style={{ marginTop: '16px' }}>
<summary className="exercise-variants-summary">
<span className="exercise-variants-summary__title">Übungsvarianten</span>
<span className="exercise-variants-summary__badge">
{variants.length === 0
? 'keine'
: `${variants.length} ${variants.length === 1 ? 'Variante' : 'Varianten'}`}
</span>
</summary>
<div className="exercise-variants-details__body">
<p className="exercise-variants-hint">
Pro Durchgang nur eine Variante bearbeiten weniger Scrollen. Reihenfolge entspricht Planung und Auswahl im
Training; Voraussetzung nutzt ihr später für Progressions-Serien.
</p>
{variants.length > 0 && (
<div className="form-row">
<label className="form-label" htmlFor="variant-edit-select">
Variante auswählen
</label>
<select
id="variant-edit-select"
className="form-input"
value={
variantEditSelection === 'new'
? 'new'
: variantEditSelection == null
? ''
: String(variantEditSelection)
}
onChange={(e) => {
const val = e.target.value
if (val === '') setVariantEditSelection(null)
else if (val === 'new') setVariantEditSelection('new')
else setVariantEditSelection(parseInt(val, 10))
}}
>
<option value=""> nicht bearbeiten </option>
{variants.map((v) => (
<option key={v.id} value={v.id}>
{(v.variant_name && String(v.variant_name).trim()) || `Variante #${v.id}`}
</option>
))}
<option value="new">+ Neue Variante anlegen</option>
</select>
</div>
)}
{variants.length === 0 && (
<p style={{ fontSize: '13px', color: 'var(--text3)', marginBottom: '10px' }}>
Noch keine Varianten optional für andere Ausführung, Dauer oder Material in Planung und Training.
</p>
)}
{variants.length === 0 && variantEditSelection !== 'new' && (
<button type="button" className="btn btn-secondary" onClick={() => setVariantEditSelection('new')}>
Erste Variante anlegen
</button>
)}
{variantEditSelection === 'new' && (
<form
className="exercise-variant-single-form"
onSubmit={createVariantSubmit}
style={{ marginTop: '14px', paddingTop: '14px', borderTop: '1px solid var(--border)' }}
>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Neue Variante</h3>
<ExerciseVariantFields
row={variantDraft}
onPatch={(patch) => setVariantDraft((d) => ({ ...d, ...patch }))}
prerequisiteOthers={variants}
rteMinHeight="110px"
/>
<button type="submit" className="btn btn-primary" style={{ marginTop: '10px' }} disabled={variantBusy}>
{variantBusy ? 'Anlegen…' : 'Variante anlegen'}
</button>
</form>
)}
{selectedVariantForEdit && (
<div
className="exercise-variant-single-form"
style={{ marginTop: '14px', paddingTop: '14px', borderTop: '1px solid var(--border)' }}
>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
alignItems: 'center',
marginBottom: '12px',
}}
>
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
Pos. {selectedVariantIdx + 1} von {variants.length}
</span>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }}
disabled={variantBusy || selectedVariantIdx <= 0}
onClick={() => moveVariantRow(selectedVariantIdx, -1)}
>
Nach oben
</button>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }}
disabled={variantBusy || selectedVariantIdx >= variants.length - 1}
onClick={() => moveVariantRow(selectedVariantIdx, 1)}
>
Nach unten
</button>
<button
type="button"
className="btn btn-primary"
style={{ marginLeft: 'auto', fontSize: '12px' }}
disabled={variantSavingId === selectedVariantForEdit.id || variantBusy}
onClick={() => saveVariantRow(selectedVariantForEdit)}
>
{variantSavingId === selectedVariantForEdit.id ? 'Speichern…' : 'Speichern'}
</button>
<button
type="button"
className="btn"
style={{ fontSize: '12px', background: 'var(--danger)', color: '#fff', border: 'none' }}
disabled={variantBusy}
onClick={() => deleteVariantRow(selectedVariantForEdit.id)}
>
Löschen
</button>
</div>
<ExerciseVariantFields
row={selectedVariantForEdit}
onPatch={(patch) => updateVariantField(selectedVariantForEdit.id, patch)}
prerequisiteOthers={variants.filter((o) => o.id !== selectedVariantForEdit.id)}
rteMinHeight="110px"
/>
</div>
)}
{variants.length > 0 && variantEditSelection == null && (
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: '12px', marginBottom: 0 }}>
Wähle eine Variante zum Bearbeiten oder Neue Variante anlegen.
</p>
)}
</div>
</details>
)}
{isEdit && (
<details className="card exercise-variants-details" style={{ marginTop: '16px' }}>
<summary className="exercise-variants-summary">
<span className="exercise-variants-summary__title">Progressionsgraph</span>
<span className="exercise-variants-summary__badge">Übung Übung</span>
</summary>
<div className="exercise-variants-details__body">
<ExerciseProgressionGraphPanel anchorExerciseId={exerciseId} anchorTitle={formData.title} />
</div>
</details>
)}
{isEdit && (
<div className="card" style={{ marginTop: '16px' }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Medien</h2>
<p style={{ color: 'var(--text2)', fontSize: '13px' }}>
Datei oder Embed (YouTube, Vimeo, Instagram, TikTok). Max. 10 pro Übung.
</p>
<div style={{ display: 'grid', gap: '12px', marginTop: '12px' }}>
<div>
<label className="form-label">Datei</label>
<input
type="file"
accept="image/jpeg,image/png,image/gif,video/mp4,application/pdf"
onChange={(e) => setMediaFile(e.target.files?.[0] || null)}
/>
<div className="form-row" style={{ marginTop: '8px' }}>
<select className="form-input" value={mediaType} onChange={(e) => setMediaType(e.target.value)}>
<option value="image">Bild</option>
<option value="video">Video</option>
<option value="document">PDF</option>
</select>
<input
type="text"
className="form-input"
placeholder="Titel (optional)"
value={mediaTitle}
onChange={(e) => setMediaTitle(e.target.value)}
style={{ marginTop: '8px' }}
/>
<select
className="form-input"
value={mediaContext}
onChange={(e) => setMediaContext(e.target.value)}
style={{ marginTop: '8px' }}
>
<option value="ablauf">Ablauf</option>
<option value="detail">Detail</option>
<option value="trainer_hint">Trainer-Hinweis</option>
</select>
</div>
<button type="button" className="btn btn-secondary" style={{ marginTop: '8px' }} onClick={handleUploadFile}>
Hochladen
</button>
</div>
<div>
<label className="form-label">Embed-URL</label>
<input
type="url"
className="form-input"
placeholder="https://…"
value={embedUrl}
onChange={(e) => setEmbedUrl(e.target.value)}
/>
<input
type="text"
className="form-input"
placeholder="Titel (optional)"
value={embedTitle}
onChange={(e) => setEmbedTitle(e.target.value)}
style={{ marginTop: '8px' }}
/>
<button type="button" className="btn btn-secondary" style={{ marginTop: '8px' }} onClick={handleAddEmbed}>
Embed hinzufügen
</button>
</div>
</div>
{mediaList.length > 0 && (
<ul style={{ marginTop: '12px', paddingLeft: '1.2rem' }}>
{mediaList.map((m) => (
<li key={m.id} style={{ marginBottom: '6px' }}>
{m.title || m.original_filename || m.media_type}{' '}
{m.embed_platform ? `(${m.embed_platform})` : ''}
<button
type="button"
className="btn"
style={{
marginLeft: '8px',
fontSize: '11px',
padding: '2px 8px',
background: 'var(--danger)',
color: '#fff',
border: 'none',
}}
onClick={() => handleDeleteMedia(m.id)}
>
Löschen
</button>
</li>
))}
</ul>
)}
</div>
)}
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}>
<strong>KI-Ausbaustufe:</strong> Backend laut Spec{' '}
<code style={{ fontSize: '11px' }}>POST /api/exercises/ai/suggest</code> und{' '}
<code style={{ fontSize: '11px' }}>POST /api/exercises/{'{id}'}/ai/regenerate</code> z.B.{' '}
<code>OPENROUTER_API_KEY</code>, Vorschläge nur nach Trainer-Bestätigung übernehmen (siehe{' '}
<code>api.suggestExerciseAi</code>).
</p>
</div>
)
}
export default ExerciseFormPage