import React, { useEffect, useState, useRef, useMemo } from 'react'
import { useNavigate, useParams, Link } from 'react-router-dom'
import api, { buildExerciseApiPayload } from '../utils/api'
import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
import RichTextEditor from '../components/RichTextEditor'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
import { useAuth } from '../context/AuthContext'
/** MIME/Dateiname → Übungs-media_type; null → Dropdown-Fallback. */
function inferExerciseMediaType(file) {
if (!file) return null
const mime = (file.type || '').toLowerCase()
if (mime.startsWith('image/')) return 'image'
if (mime.startsWith('video/')) return 'video'
if (mime === 'application/pdf' || mime.includes('pdf')) return 'document'
const name = (file.name || '').toLowerCase()
if (/\.(mp4|webm|mov|mkv|avi|m4v|mpeg|mpg)$/.test(name)) return 'video'
if (/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/.test(name)) return 'image'
if (/\.pdf$/.test(name)) return 'document'
return null
}
/** Kachelvorschau: Video nutzt ersten Frame (metadata), Bild = img. */
function ExerciseMediaThumbTile({ exerciseId, media, onOpenPreview }) {
const src = !media.embed_url ? resolveExerciseMediaFileUrl(exerciseId, media) : null
const commonStyle = {
width: '100%',
height: '100%',
objectFit: 'cover',
}
return (
onOpenPreview(media)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onOpenPreview(media)
}
}}
style={{
width: 72,
height: 72,
flexShrink: 0,
borderRadius: '8px',
overflow: 'hidden',
background: 'var(--surface2, rgba(127,127,127,0.12))',
border: '1px solid var(--border)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{media.embed_url ? (
{media.embed_platform || 'Embed'}
) : (media.mime_type?.startsWith('image/') || media.media_type === 'image') && src ? (
) : (media.mime_type?.startsWith('video/') || media.media_type === 'video') && src ? (
{
try {
const el = e.currentTarget
const d = el.duration
el.currentTime = Number.isFinite(d) && d > 0 ? Math.min(0.05, d * 0.01) : 0.05
} catch (_) {
/* ignore */
}
}}
/>
) : (
Datei
)}
)
}
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', exerciseMediaInsertSlots }) {
return (
<>
Variantenname *
onPatch({ variant_name: e.target.value })}
minLength={3}
/>
Kurzbeschreibung
Abweichungen zur Durchführung
onPatch({ execution_changes: html })}
placeholder="Was unterscheidet diese Variante? (Listen über Symbolleiste)"
minHeight={rteMinHeight}
insertExerciseMediaSlots={exerciseMediaInsertSlots}
/>
Materialänderungen (eine Zeile pro Eintrag)
Schwere relativ
onPatch({ difficulty_adjustment: e.target.value })}
>
{VARIANT_DIFFICULTY.map((o) => (
{o.label}
))}
Progressions-Stufe (1–10)
onPatch({
progression_level: e.target.value === '' ? '' : parseInt(e.target.value, 10),
})
}
/>
Voraussetzungs-Variante
onPatch({
prerequisite_variant_id: e.target.value === '' ? '' : parseInt(e.target.value, 10),
})
}
>
— keine —
{prerequisiteOthers.map((o) => (
{o.variant_name || `Variante #${o.id}`}
))}
>
)
}
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 (
{title}
+ Eintrag
{rows.length === 0 && (
{emptyLabel}
)}
{rows.map((row, idx) => (
updateRow(idx, { [idKey]: e.target.value ? parseInt(e.target.value, 10) : '' })}
>
— wählen —
{options.map((o) => (
{o.icon ? `${o.icon} ` : ''}
{o.name}
{o.abbreviation ? ` (${o.abbreviation})` : ''}
))}
setPrimary(idx)}
/>
primär
removeRow(idx)}>
✕
))}
)
}
function ExerciseFormPage() {
const { id: routeId } = useParams()
const navigate = useNavigate()
const { user } = useAuth()
const isSuperadmin = user?.role === 'superadmin'
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 [mediaFiles, setMediaFiles] = useState([])
const [mediaType, setMediaType] = useState('image')
const [mediaTitle, setMediaTitle] = useState('')
const [mediaContext, setMediaContext] = useState('ablauf')
const [embedUrl, setEmbedUrl] = useState('')
const [embedTitle, setEmbedTitle] = useState('')
const [mediaFields, setMediaFields] = useState({})
const [mediaSavingId, setMediaSavingId] = useState(null)
const [archiveOpen, setArchiveOpen] = useState(false)
const [archiveQ, setArchiveQ] = useState('')
const [archiveCtx, setArchiveCtx] = useState('ablauf')
const [archiveLoading, setArchiveLoading] = useState(false)
const [archiveItems, setArchiveItems] = useState([])
const [archiveError, setArchiveError] = useState(null)
const [mediaPreview, setMediaPreview] = useState(null)
const exerciseMediaInsertSlots = useMemo(() => {
if (!isEdit) return []
return (mediaList || [])
.filter((m) => m?.id != null)
.map((m) => ({
id: m.id,
label: (m.title && String(m.title).trim()) || m.original_filename || `Medium #${m.id}`,
}))
}, [isEdit, mediaList])
useEffect(() => {
const next = {}
for (const m of mediaList) {
next[m.id] = {
title: m.title || '',
context: m.context || 'ablauf',
}
}
setMediaFields(next)
}, [mediaList])
useEffect(() => {
if (!archiveOpen) return undefined
let cancelled = false
const t = setTimeout(async () => {
setArchiveLoading(true)
setArchiveError(null)
try {
const res = await api.listMediaAssets({
q: archiveQ.trim() || undefined,
limit: 40,
})
if (!cancelled) setArchiveItems(res.items || [])
} catch (e) {
if (!cancelled) setArchiveError(e.message || String(e))
} finally {
if (!cancelled) setArchiveLoading(false)
}
}, 280)
return () => {
cancelled = true
clearTimeout(t)
}
}, [archiveOpen, archiveQ])
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) {
const saveOnce = (extras = {}) =>
api.updateExercise(exerciseId, buildExerciseApiPayload(payloadBase, extras))
try {
await saveOnce()
} catch (firstErr) {
if (
firstErr.status === 422 &&
firstErr.code === 'OFFICIAL_MEDIA_LIFECYCLE' &&
firstErr.payload?.media_assets
) {
alert(
'Speichern nicht möglich: mindestens ein verknüpftes Medium ist nicht aktiv (Papierkorb). ' +
'Bitte Medium wiederherstellen oder aus der Übung entfernen.',
)
throw firstErr
}
if (firstErr.status === 422 && firstErr.code === 'OFFICIAL_MEDIA_CONFIRM_REQUIRED') {
const promo = (firstErr.payload.assets_need_visibility_promotion || []).length
const miss = (firstErr.payload.assets_missing_copyright || []).length
let msg =
'Die Übung ist oder wird offiziell. '
if (promo > 0) {
msg += `${promo} verknüpfte Datei(en) werden dabei plattformweit („offiziell“) freigegeben. `
}
if (miss > 0) {
msg += `${miss} Datei(en) haben noch keinen ausreichenden Copyright-Vermerk (mind. 3 Zeichen). `
}
msg += 'Fortfahren?'
if (!window.confirm(msg)) throw firstErr
let defaultCopyright = ''
if (miss > 0) {
defaultCopyright = window.prompt(
'Copyright-Vermerk für betroffene Dateien ohne Eintrag (mind. 3 Zeichen):',
'© ',
)
if (!defaultCopyright || String(defaultCopyright).trim().length < 3) {
alert('Mindestens 3 Zeichen für den Copyright-Vermerk.')
throw firstErr
}
}
await saveOnce({
promote_attached_media_for_official: true,
...(miss > 0 ? { default_official_media_copyright: String(defaultCopyright).trim() } : {}),
})
} else if (
firstErr.status === 422 &&
firstErr.code === 'CLUB_MEDIA_COPYRIGHT_REQUIRED' &&
firstErr.payload?.media_assets
) {
alert(
'Vereinsöffentliche Übungen brauchen bei jeder verknüpften Datei einen Copyright-Vermerk (mind. 3 Zeichen). Bitte in der Medienbibliothek oder den Mediendetails nachtragen.',
)
throw firstErr
} else if (firstErr.status === 422 && firstErr.code === 'CLUB_MEDIA_LIFECYCLE') {
alert(
'Speichern nicht möglich: mindestens ein verknüpftes Medium ist nicht aktiv (Papierkorb). Bitte reaktivieren oder entfernen.',
)
throw firstErr
} else {
throw firstErr
}
}
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 attachFromArchive = async (assetId) => {
if (!exerciseId) return
try {
await api.attachExerciseMediaFromAsset(exerciseId, {
media_asset_id: assetId,
context: archiveCtx,
title: '',
description: '',
is_primary: false,
})
setArchiveOpen(false)
await refreshMedia()
} catch (e) {
alert(e.message || String(e))
}
}
const linkedArchiveAssetIds = useMemo(
() => new Set((mediaList || []).map((m) => m.media_asset_id).filter(Boolean)),
[mediaList],
)
const handleUploadFile = async () => {
if (!exerciseId || mediaFiles.length === 0) {
alert('Datei(en) wählen')
return
}
const files = [...mediaFiles]
for (const file of files) {
const inferred = inferExerciseMediaType(file) || mediaType
const fd = new FormData()
fd.append('file', file)
fd.append('media_type', inferred)
fd.append('title', mediaTitle)
fd.append('description', '')
fd.append('context', mediaContext)
fd.append('is_primary', 'false')
try {
await api.uploadExerciseMedia(exerciseId, fd)
} catch (err) {
if (err.code === 'MEDIA_ASSET_IN_TRASH' && err.payload?.media_asset_id != null) {
const aid = err.payload.media_asset_id
const nameHint = file?.name || err.payload.original_filename || 'diese Datei'
if (
confirm(
`Die hochgeladene Datei ist inhaltsgleich mit einem Archiv-Medium im Papierkorb (${nameHint}). ` +
'Soll dieses Medium wieder aktiviert und an die Übung gehängt werden? (Es wird kein zweites Exemplar auf der Platte angelegt.)',
)
) {
try {
await api.postMediaAssetLifecycle(aid, 'reactivate')
await api.attachExerciseMediaFromAsset(exerciseId, {
media_asset_id: aid,
title: mediaTitle || undefined,
description: '',
context: mediaContext,
is_primary: false,
})
} catch (e2) {
alert(e2.message || String(e2))
return
}
} else {
return
}
} else if (err.code === 'MEDIA_ASSET_UNAVAILABLE') {
alert(
(err.message || 'Archiv-Konflikt') +
' Bitte wenden Sie sich an einen Administrator oder wählen Sie eine andere Datei.',
)
return
} else {
alert(`Upload (${file.name}): ${err.message || String(err)}`)
return
}
}
}
setMediaFiles([])
setMediaTitle('')
await refreshMedia()
}
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(
'Dieses Medium aus der Übung entfernen? Nur die Verknüpfung wird gelöscht. Die Datei bleibt im Archiv, solange sie noch woanders genutzt wird.',
)
) {
return
}
try {
const res = await api.deleteExerciseMedia(exerciseId, mid)
await refreshMedia()
const oid = res?.orphan_media_asset_id
if (oid != null) {
if (
confirm(
'Dieses Archiv-Medium wird danach nirgendwo mehr verwendet. In den Papierkorb (Stufe 1) legen? (Später in der Medienverwaltung wiederherstellbar.)',
)
) {
await api.postMediaAssetLifecycle(oid, 'trash_soft')
await refreshMedia()
}
}
} catch (err) {
alert(err.message)
}
}
const moveMediaRow = async (idx, dir) => {
if (!exerciseId) return
const j = idx + dir
if (j < 0 || j >= mediaList.length) return
const next = [...mediaList]
const tmp = next[idx]
next[idx] = next[j]
next[j] = tmp
try {
await api.reorderExerciseMedia(
exerciseId,
next.map((x) => x.id),
)
setMediaList(next)
} catch (e) {
alert(e.message || String(e))
}
}
const saveMediaMeta = async (mid) => {
if (!exerciseId) return
const fld = mediaFields[mid]
if (!fld) return
setMediaSavingId(mid)
try {
await api.updateExerciseMedia(exerciseId, mid, {
title: fld.title.trim() || null,
context: fld.context,
})
await refreshMedia()
} catch (e) {
alert(e.message || String(e))
} finally {
setMediaSavingId(null)
}
}
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 (
)
}
return (
navigate('/exercises')}>
← Übersicht
{isEdit && (
navigate(`/exercises/${exerciseId}`)}
>
Ansehen
)}
{isEdit ? 'Übung bearbeiten' : 'Neue Übung'}
{isEdit && (
Übungsvarianten
{variants.length === 0
? 'keine'
: `${variants.length} ${variants.length === 1 ? 'Variante' : 'Varianten'}`}
Pro Durchgang nur eine Variante bearbeiten – weniger Scrollen. Reihenfolge entspricht Planung und Auswahl im
Training; „Voraussetzung“ nutzt ihr später für Progressions-Serien.
{variants.length > 0 && (
Variante auswählen
{
const val = e.target.value
if (val === '') setVariantEditSelection(null)
else if (val === 'new') setVariantEditSelection('new')
else setVariantEditSelection(parseInt(val, 10))
}}
>
— nicht bearbeiten —
{variants.map((v) => (
{(v.variant_name && String(v.variant_name).trim()) || `Variante #${v.id}`}
))}
+ Neue Variante anlegen…
)}
{variants.length === 0 && (
Noch keine Varianten – optional für andere Ausführung, Dauer oder Material in Planung und Training.
)}
{variants.length === 0 && variantEditSelection !== 'new' && (
setVariantEditSelection('new')}>
Erste Variante anlegen
)}
{variantEditSelection === 'new' && (
Neue Variante
setVariantDraft((d) => ({ ...d, ...patch }))}
prerequisiteOthers={variants}
rteMinHeight="110px"
exerciseMediaInsertSlots={exerciseMediaInsertSlots}
/>
{variantBusy ? 'Anlegen…' : 'Variante anlegen'}
)}
{selectedVariantForEdit && (
Pos. {selectedVariantIdx + 1} von {variants.length}
moveVariantRow(selectedVariantIdx, -1)}
>
Nach oben
= variants.length - 1}
onClick={() => moveVariantRow(selectedVariantIdx, 1)}
>
Nach unten
saveVariantRow(selectedVariantForEdit)}
>
{variantSavingId === selectedVariantForEdit.id ? 'Speichern…' : 'Speichern'}
deleteVariantRow(selectedVariantForEdit.id)}
>
Löschen
updateVariantField(selectedVariantForEdit.id, patch)}
prerequisiteOthers={variants.filter((o) => o.id !== selectedVariantForEdit.id)}
rteMinHeight="110px"
exerciseMediaInsertSlots={exerciseMediaInsertSlots}
/>
)}
{variants.length > 0 && variantEditSelection == null && (
Wähle eine Variante zum Bearbeiten oder „Neue Variante anlegen…“.
)}
)}
{isEdit && (
Progressionsgraph
Übung → Übung
)}
{isEdit && (
Medien
Datei oder Embed (YouTube, Vimeo, Instagram, TikTok). Max. 10 pro Übung.
setArchiveOpen(true)}>
Aus Archiv verknüpfen…
Medienbibliothek
{mediaList.length > 0 && (
{mediaList.map((m, idx) => (
#{idx + 1} · {m.media_type}
{m.embed_platform ? ` · ${m.embed_platform}` : ''}
{mediaList.length > 1 && (
<>
moveMediaRow(idx, -1)}
title="Nach oben"
>
↑
= mediaList.length - 1}
onClick={() => moveMediaRow(idx, 1)}
title="Nach unten"
>
↓
>
)}
{(m.original_filename || '').trim() ||
(m.title || '').trim() ||
(m.embed_url ? m.embed_url : '') ||
'—'}
setMediaFields((prev) => ({
...prev,
[m.id]: { ...(prev[m.id] || {}), title: e.target.value, context: (prev[m.id] || {}).context || 'ablauf' },
}))
}
/>
setMediaFields((prev) => ({
...prev,
[m.id]: {
...(prev[m.id] || {}),
title: (prev[m.id] || {}).title ?? '',
context: e.target.value,
},
}))
}
>
Ablauf
Detail
Trainer-Hinweis
saveMediaMeta(m.id)}
>
{mediaSavingId === m.id ? 'Speichern…' : 'Titel & Sektion speichern'}
handleDeleteMedia(m.id)}
>
Aus Übung entfernen
))}
)}
{archiveOpen && (
setArchiveOpen(false)}
onKeyDown={(e) => e.key === 'Escape' && setArchiveOpen(false)}
>
e.stopPropagation()}
>
Medienarchiv
setArchiveQ(e.target.value)}
style={{ marginBottom: '8px' }}
/>
setArchiveCtx(e.target.value)}
style={{ marginBottom: '12px' }}
>
Sektion: Ablauf
Sektion: Detail
Sektion: Trainer-Hinweis
{archiveLoading &&
Laden…
}
{archiveError &&
{archiveError}
}
{!archiveLoading && !archiveError && archiveItems.length === 0 && (
Keine Treffer.
)}
{archiveItems.map((a) => {
const already = linkedArchiveAssetIds.has(a.id)
return (
{a.mime_type?.startsWith('image/') ? (
) : a.mime_type?.startsWith('video/') ? (
▶
) : (
PDF
)}
{a.original_filename || `Asset #${a.id}`}
{a.visibility} · {a.mime_type || '—'}{' '}
{a.byte_size != null ? `· ${(a.byte_size / 1024).toFixed(0)} KB` : ''}
!already && attachFromArchive(a.id)}
>
{already ? 'Bereits verknüpft' : 'Verknüpfen'}
)
})}
setArchiveOpen(false)}>
Schließen
)}
{mediaPreview && (
setMediaPreview(null)}
onKeyDown={(e) => e.key === 'Escape' && setMediaPreview(null)}
>
e.stopPropagation()}
>
Vorschau
{mediaPreview.embed_url ? (
{mediaPreview.embed_url}
) : mediaPreview.mime_type?.startsWith('video/') || mediaPreview.media_type === 'video' ? (
) : mediaPreview.mime_type?.startsWith('image/') || mediaPreview.media_type === 'image' ? (
) : (
Datei öffnen
)}
setMediaPreview(null)}>
Schließen
)}
)}
KI-Ausbaustufe: Backend laut Spec{' '}
POST /api/exercises/ai/suggest und{' '}
POST /api/exercises/{'{id}'}/ai/regenerate — z. B.{' '}
OPENROUTER_API_KEY, Vorschläge nur nach Trainer-Bestätigung übernehmen (siehe{' '}
api.suggestExerciseAi).
)
}
export default ExerciseFormPage