2026-05-07 13:00:57 +02:00
import React , { useEffect , useState , useRef , useMemo } from 'react'
2026-05-07 14:10:26 +02:00
import { useNavigate , useParams , Link } from 'react-router-dom'
2026-04-27 14:27:25 +02:00
import api , { buildExerciseApiPayload } from '../utils/api'
2026-05-07 13:00:57 +02:00
import { resolveExerciseMediaFileUrl , resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
2026-04-27 14:48:46 +02:00
import RichTextEditor from '../components/RichTextEditor'
2026-04-30 11:47:50 +02:00
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
2026-05-08 12:20:24 +02:00
import ExerciseMediaThumbTile from '../components/ExerciseMediaThumbTile'
import {
SHINKAN _EXERCISE _MEDIA _DRAG _MIME ,
buildExerciseMediaDragPayload ,
} from '../utils/exerciseInlineMediaRefs'
2026-05-08 12:35:28 +02:00
import { autoScrollForDragNearEdges } from '../utils/dragAutoScroll'
2026-04-27 18:25:23 +02:00
import { SKILL _LEVEL _OPTIONS , normalizeSkillLevelSlug } from '../constants/skillLevels'
2026-05-08 10:20:41 +02:00
import { useAuth } from '../context/AuthContext'
2026-04-27 14:27:25 +02:00
const INTENSITY _OPTIONS = [
{ value : '' , label : '—' } ,
{ value : 'niedrig' , label : 'niedrig' } ,
{ value : 'mittel' , label : 'mittel' } ,
{ value : 'hoch' , label : 'hoch' } ,
]
2026-04-28 10:59:09 +02:00
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 ) ,
}
}
2026-04-28 13:09:42 +02:00
/** Gemeinsame Felder für „Variante bearbeiten“ und „Neue Variante“. */
2026-05-08 12:00:02 +02:00
function ExerciseVariantFields ( {
row ,
onPatch ,
prerequisiteOthers ,
rteMinHeight = '110px' ,
inlineExerciseId ,
2026-05-08 12:20:24 +02:00
linkedExerciseMedia = [ ] ,
2026-05-08 12:00:02 +02:00
onExerciseMediaListChanged ,
} ) {
2026-04-28 13:09:42 +02:00
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 }
2026-05-08 12:00:02 +02:00
inlineExerciseId = { inlineExerciseId }
2026-05-08 12:20:24 +02:00
linkedExerciseMedia = { linkedExerciseMedia }
2026-05-08 12:00:02 +02:00
onExerciseMediaListChanged = { onExerciseMediaListChanged }
2026-04-28 13:09:42 +02:00
/ >
< / 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 ( 1 – 10 ) < / 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 >
< / >
)
}
2026-04-27 14:27:25 +02:00
function emptyForm ( ) {
return {
title : '' ,
summary : '' ,
goal : '' ,
execution : '' ,
preparation : '' ,
trainer _notes : '' ,
2026-04-27 14:48:46 +02:00
equipmentLines : '' ,
2026-04-27 14:27:25 +02:00
duration _min : '' ,
duration _max : '' ,
group _size _min : '' ,
group _size _max : '' ,
2026-04-27 14:48:46 +02:00
focus _areas _multi : [ ] ,
training _styles _multi : [ ] ,
training _types _multi : [ ] ,
target _groups _multi : [ ] ,
2026-04-27 14:27:25 +02:00
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 || '' ,
2026-04-27 14:48:46 +02:00
equipmentLines : ( exercise . equipment || [ ] ) . join ( '\n' ) ,
2026-04-27 14:27:25 +02:00
duration _min : exercise . duration _min ? ? '' ,
duration _max : exercise . duration _max ? ? '' ,
group _size _min : exercise . group _size _min ? ? '' ,
group _size _max : exercise . group _size _max ? ? '' ,
2026-04-27 14:48:46 +02:00
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 ,
} ) ) ,
2026-04-27 14:27:25 +02:00
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 || '' ,
2026-04-27 18:25:23 +02:00
required _level : normalizeSkillLevelSlug ( s . required _level ) ,
target _level : normalizeSkillLevelSlug ( s . target _level ) ,
2026-04-27 14:27:25 +02:00
} ) ) || [ ] ,
}
}
2026-04-27 14:48:46 +02:00
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 >
)
}
2026-04-27 14:27:25 +02:00
function ExerciseFormPage ( ) {
const { id : routeId } = useParams ( )
const navigate = useNavigate ( )
2026-05-08 10:20:41 +02:00
const { user } = useAuth ( )
const isSuperadmin = user ? . role === 'superadmin'
2026-04-27 14:27:25 +02:00
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 ( [ ] )
2026-04-27 14:48:46 +02:00
const [ styleDirections , setStyleDirections ] = useState ( [ ] )
const [ trainingTypes , setTrainingTypes ] = useState ( [ ] )
const [ targetGroups , setTargetGroups ] = useState ( [ ] )
2026-04-27 14:27:25 +02:00
const [ mediaList , setMediaList ] = useState ( [ ] )
const [ loading , setLoading ] = useState ( ! ! isEdit )
const [ saving , setSaving ] = useState ( false )
2026-05-08 12:35:28 +02:00
const [ formDirty , setFormDirty ] = useState ( false )
2026-04-27 14:48:46 +02:00
const [ skillPick , setSkillPick ] = useState ( '' )
2026-04-28 10:59:09 +02:00
const [ variants , setVariants ] = useState ( [ ] )
const [ variantDraft , setVariantDraft ] = useState ( ( ) => emptyVariantDraft ( ) )
const [ variantSavingId , setVariantSavingId ] = useState ( null )
const [ variantBusy , setVariantBusy ] = useState ( false )
2026-04-28 13:09:42 +02:00
const [ variantEditSelection , setVariantEditSelection ] = useState ( null )
const variantsDetailsRef = useRef ( null )
2026-04-27 14:27:25 +02:00
2026-05-07 12:46:59 +02:00
const [ mediaFields , setMediaFields ] = useState ( { } )
const [ mediaSavingId , setMediaSavingId ] = useState ( null )
2026-05-07 13:00:57 +02:00
const [ archiveOpen , setArchiveOpen ] = useState ( false )
const [ archiveQ , setArchiveQ ] = useState ( '' )
const [ archiveLoading , setArchiveLoading ] = useState ( false )
const [ archiveItems , setArchiveItems ] = useState ( [ ] )
const [ archiveError , setArchiveError ] = useState ( null )
const [ mediaPreview , setMediaPreview ] = useState ( null )
2026-05-07 12:46:59 +02:00
useEffect ( ( ) => {
const next = { }
for ( const m of mediaList ) {
next [ m . id ] = {
title : m . title || '' ,
}
}
setMediaFields ( next )
} , [ mediaList ] )
2026-04-27 14:27:25 +02:00
2026-05-08 12:35:28 +02:00
useEffect ( ( ) => {
const onDragOverDoc = ( e ) => {
const types = e . dataTransfer ? . types ? Array . from ( e . dataTransfer . types ) : [ ]
if ( ! types . includes ( SHINKAN _EXERCISE _MEDIA _DRAG _MIME ) ) return
e . preventDefault ( )
autoScrollForDragNearEdges ( e )
}
document . addEventListener ( 'dragover' , onDragOverDoc )
return ( ) => document . removeEventListener ( 'dragover' , onDragOverDoc )
} , [ ] )
useEffect ( ( ) => {
if ( ! formDirty ) return undefined
const warn = ( ev ) => {
ev . preventDefault ( )
ev . returnValue = ''
}
window . addEventListener ( 'beforeunload' , warn )
return ( ) => window . removeEventListener ( 'beforeunload' , warn )
} , [ formDirty ] )
2026-05-07 13:00:57 +02:00
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 ] )
2026-04-27 14:27:25 +02:00
useEffect ( ( ) => {
let cancelled = false
const boot = async ( ) => {
try {
2026-04-27 14:48:46 +02:00
const [ skillsData , faData , sdData , ttData , tgData ] = await Promise . all ( [
2026-04-27 14:27:25 +02:00
api . listSkills ( ) ,
api . listFocusAreas ( ) ,
api . listTrainingStyles ( ) ,
2026-04-27 14:48:46 +02:00
api . listTrainingTypes ( ) ,
api . listTargetGroups ( ) ,
2026-04-27 14:27:25 +02:00
] )
if ( cancelled ) return
setSkillsCatalog ( skillsData )
setFocusAreas ( faData )
2026-04-27 14:48:46 +02:00
setStyleDirections ( sdData )
setTrainingTypes ( ttData )
setTargetGroups ( tgData )
2026-04-27 14:27:25 +02:00
} catch ( e ) {
2026-04-27 15:01:47 +02:00
if ( ! cancelled ) {
console . error ( e )
alert (
'Kataloge (Fokus, Stile, Zielgruppen, Fähigkeiten) konnten nicht geladen werden: ' +
( e . message || e ) ,
)
}
2026-04-27 14:27:25 +02:00
}
}
boot ( )
return ( ) => {
cancelled = true
}
} , [ ] )
useEffect ( ( ) => {
if ( ! isEdit ) {
setFormData ( emptyForm ( ) )
setMediaList ( [ ] )
2026-04-28 10:59:09 +02:00
setVariants ( [ ] )
setVariantDraft ( emptyVariantDraft ( ) )
2026-04-28 13:09:42 +02:00
setVariantEditSelection ( null )
2026-05-08 12:35:28 +02:00
setFormDirty ( false )
2026-04-27 14:27:25 +02:00
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 || [ ] )
2026-04-28 10:59:09 +02:00
setVariants ( ( exercise . variants || [ ] ) . map ( apiVariantToRow ) )
setVariantDraft ( emptyVariantDraft ( ) )
2026-04-28 13:09:42 +02:00
setVariantEditSelection ( null )
2026-05-08 12:35:28 +02:00
setFormDirty ( false )
2026-04-27 14:27:25 +02:00
} catch ( err ) {
if ( ! cancelled ) {
alert ( err . message || 'Übung nicht ladbar' )
navigate ( '/exercises' )
}
} finally {
if ( ! cancelled ) setLoading ( false )
}
}
load ( )
return ( ) => {
cancelled = true
}
} , [ isEdit , exerciseId , navigate ] )
2026-04-28 13:09:42 +02:00
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 ] )
2026-04-27 14:27:25 +02:00
const updateFormField = ( field , value ) => {
2026-05-08 12:35:28 +02:00
setFormDirty ( true )
2026-04-27 14:27:25 +02:00
setFormData ( ( prev ) => ( { ... prev , [ field ] : value } ) )
}
2026-04-27 14:48:46 +02:00
const addSkillRow = ( ) => {
const id = skillPick ? parseInt ( skillPick , 10 ) : null
if ( ! id ) {
alert ( 'Fähigkeit wählen' )
return
2026-04-27 14:27:25 +02:00
}
2026-04-27 14:48:46 +02:00
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 } ) ) ,
)
2026-04-27 14:27:25 +02:00
}
2026-04-27 14:48:46 +02:00
const updateSkillField = ( idx , field , value ) => {
2026-04-27 14:27:25 +02:00
updateFormField (
'skills' ,
2026-04-27 14:48:46 +02:00
formData . skills . map ( ( s , i ) => ( i === idx ? { ... s , [ field ] : value } : s ) ) ,
2026-04-27 14:27:25 +02:00
)
}
2026-04-27 14:48:46 +02:00
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 )
}
2026-04-27 14:27:25 +02:00
const handleSubmit = async ( e ) => {
e . preventDefault ( )
if ( ! formData . title || formData . title . trim ( ) . length < 3 ) {
alert ( 'Titel mindestens 3 Zeichen' )
return
}
2026-04-27 14:48:46 +02:00
const payloadBase = {
... formData ,
equipment :
typeof formData . equipmentLines === 'string'
? formData . equipmentLines
. split ( /[\n,]+/ )
. map ( ( s ) => s . trim ( ) )
. filter ( Boolean )
: [ ] ,
}
2026-04-27 14:27:25 +02:00
let payload
try {
2026-04-27 14:48:46 +02:00
payload = buildExerciseApiPayload ( payloadBase )
2026-04-27 14:27:25 +02:00
} catch ( err ) {
alert ( err . message )
return
}
setSaving ( true )
try {
if ( isEdit ) {
2026-05-07 13:34:22 +02:00
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 ( ) } : { } ) ,
} )
2026-05-08 10:20:41 +02:00
} else if (
firstErr . status === 422 &&
firstErr . code === 'CLUB_MEDIA_COPYRIGHT_REQUIRED' &&
firstErr . payload ? . media _assets
) {
2026-05-09 09:03:21 +02:00
const miss = firstErr . payload . media _assets . length
const msg =
` Vereinsöffentliche Übungen brauchen bei jeder verknüpften Datei einen Copyright-Vermerk (mind. 3 Zeichen). ` +
` ${ miss } Datei(en) sind noch ohne ausreichenden Vermerk. ` +
` Beim Speichern einen gemeinsamen Vermerk für diese Dateien setzen? `
if ( ! window . confirm ( msg ) ) throw firstErr
const defaultCopyright = window . prompt (
'Copyright-Vermerk für die betroffenen Dateien (mind. 3 Zeichen):' ,
'© ' ,
2026-05-08 10:20:41 +02:00
)
2026-05-09 09:03:21 +02:00
if ( ! defaultCopyright || String ( defaultCopyright ) . trim ( ) . length < 3 ) {
alert ( 'Mindestens 3 Zeichen für den Copyright-Vermerk.' )
throw firstErr
}
await saveOnce ( {
default _club _media _copyright : String ( defaultCopyright ) . trim ( ) ,
} )
2026-05-08 10:20:41 +02:00
} 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
2026-05-07 13:34:22 +02:00
} else {
throw firstErr
}
}
2026-04-27 14:27:25 +02:00
const ex = await api . getExercise ( exerciseId )
setMediaList ( ex . media || [ ] )
2026-04-28 10:59:09 +02:00
setVariants ( ( ex . variants || [ ] ) . map ( apiVariantToRow ) )
2026-05-08 12:35:28 +02:00
setFormDirty ( false )
2026-04-27 14:27:25 +02:00
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 || [ ] )
}
2026-05-07 13:00:57 +02:00
const attachFromArchive = async ( assetId ) => {
if ( ! exerciseId ) return
try {
await api . attachExerciseMediaFromAsset ( exerciseId , {
media _asset _id : assetId ,
2026-05-08 12:35:28 +02:00
context : 'ablauf' ,
2026-05-07 13:00:57 +02:00
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 ] ,
)
2026-04-27 14:27:25 +02:00
const handleDeleteMedia = async ( mid ) => {
2026-05-07 13:10:37 +02:00
if (
! confirm (
2026-05-08 12:35:28 +02:00
'Dieses Medium aus der Übung entfernen? Nur die Verknüpfung wird gelöscht. Die Datei bleibt im Archiv, solange sie noch woanders genutzt wird.\n\n' +
'Hinweis: Wenn dieser Eintrag noch als Platzhalter im Fließtext steht, zeigt die Vorschau [Medium nicht verfügbar] oder das Speichern der Übung schlägt fehl, bis der Platzhalter entfernt ist.' ,
2026-05-07 13:10:37 +02:00
)
) {
return
}
2026-04-27 14:27:25 +02:00
try {
2026-05-07 13:10:37 +02:00
const res = await api . deleteExerciseMedia ( exerciseId , mid )
2026-04-27 14:27:25 +02:00
await refreshMedia ( )
2026-05-07 13:10:37 +02:00
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 ( )
}
}
2026-04-27 14:27:25 +02:00
} catch ( err ) {
alert ( err . message )
}
}
2026-05-07 12:46:59 +02:00
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 ,
} )
await refreshMedia ( )
} catch ( e ) {
alert ( e . message || String ( e ) )
} finally {
setMediaSavingId ( null )
}
}
2026-04-28 10:59:09 +02:00
const refreshVariants = async ( ) => {
if ( ! exerciseId ) return
const ex = await api . getExercise ( exerciseId )
setVariants ( ( ex . variants || [ ] ) . map ( apiVariantToRow ) )
}
const updateVariantField = ( id , patch ) => {
2026-05-08 12:35:28 +02:00
setFormDirty ( true )
2026-04-28 10:59:09 +02:00
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 )
2026-04-28 13:09:42 +02:00
if ( variantEditSelection === id ) setVariantEditSelection ( null )
2026-04-28 10:59:09 +02:00
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 {
2026-04-28 13:09:42 +02:00
const created = await api . createExerciseVariant ( exerciseId , payload )
2026-04-28 10:59:09 +02:00
setVariantDraft ( emptyVariantDraft ( ) )
await refreshVariants ( )
2026-04-28 13:09:42 +02:00
if ( created ? . id != null ) setVariantEditSelection ( created . id )
else setVariantEditSelection ( null )
2026-04-28 10:59:09 +02:00
} catch ( err ) {
alert ( err . message || String ( err ) )
} finally {
setVariantBusy ( false )
}
}
2026-04-27 14:48:46 +02:00
const availableSkills = skillsCatalog . filter ( ( s ) => ! formData . skills . some ( ( x ) => x . skill _id === s . id ) )
2026-04-28 13:09:42 +02:00
const selectedVariantForEdit =
typeof variantEditSelection === 'number' ? variants . find ( ( v ) => v . id === variantEditSelection ) : null
const selectedVariantIdx = selectedVariantForEdit
? variants . findIndex ( ( v ) => v . id === selectedVariantForEdit . id )
: - 1
2026-04-27 14:27:25 +02:00
if ( loading ) {
return (
< div style = { { padding : '2rem' , textAlign : 'center' } } >
< div className = "spinner" > < / div >
< p > Laden ... < / p >
< / div >
)
}
return (
2026-05-05 12:41:59 +02:00
< div style = { { padding : '12px' } } className = "app-page" >
2026-04-27 14:48:46 +02:00
< 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' } }
2026-05-08 12:35:28 +02:00
onClick = { ( ) => {
if (
formDirty &&
! window . confirm (
'Es gibt noch nicht über „Speichern“ gesicherte Änderungen (Texte, Zuordnungen, …).\n\n' +
'Zur Ansicht wechseln und diese Änderungen verwerfen?' ,
)
) {
return
}
navigate ( ` /exercises/ ${ exerciseId } ` , { state : { fromExerciseEdit : true } } )
} }
2026-04-27 14:48:46 +02:00
>
Ansehen
2026-04-27 14:27:25 +02:00
< / button >
2026-04-27 14:48:46 +02:00
) }
< / div >
2026-04-27 14:27:25 +02:00
2026-04-27 14:48:46 +02:00
< 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 >
2026-04-27 14:27:25 +02:00
2026-04-27 14:48:46 +02:00
< div className = "form-row" >
< label className = "form-label" > Kurzbeschreibung < / label >
< RichTextEditor
value = { formData . summary }
onChange = { ( html ) => updateFormField ( 'summary' , html ) }
placeholder = "Kurzbeschreibung (optional)"
minHeight = "80px"
2026-05-08 12:00:02 +02:00
inlineExerciseId = { isEdit ? exerciseId : null }
2026-05-08 12:20:24 +02:00
linkedExerciseMedia = { isEdit ? mediaList : [ ] }
2026-05-08 12:00:02 +02:00
onExerciseMediaListChanged = { refreshMedia }
2026-04-27 14:48:46 +02:00
/ >
< / div >
2026-04-27 14:27:25 +02:00
2026-04-27 14:48:46 +02:00
< div className = "form-row" >
< label className = "form-label" > Ziel * < / label >
< RichTextEditor
value = { formData . goal }
onChange = { ( html ) => updateFormField ( 'goal' , html ) }
placeholder = "Trainingsziel"
minHeight = "120px"
2026-05-08 12:00:02 +02:00
inlineExerciseId = { isEdit ? exerciseId : null }
2026-05-08 12:20:24 +02:00
linkedExerciseMedia = { isEdit ? mediaList : [ ] }
2026-05-08 12:00:02 +02:00
onExerciseMediaListChanged = { refreshMedia }
2026-04-27 14:48:46 +02:00
/ >
< / div >
2026-04-27 14:27:25 +02:00
2026-04-27 14:48:46 +02:00
< 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"
2026-05-08 12:00:02 +02:00
inlineExerciseId = { isEdit ? exerciseId : null }
2026-05-08 12:20:24 +02:00
linkedExerciseMedia = { isEdit ? mediaList : [ ] }
2026-05-08 12:00:02 +02:00
onExerciseMediaListChanged = { refreshMedia }
2026-04-27 14:48:46 +02:00
/ >
< / 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"
2026-05-08 12:00:02 +02:00
inlineExerciseId = { isEdit ? exerciseId : null }
2026-05-08 12:20:24 +02:00
linkedExerciseMedia = { isEdit ? mediaList : [ ] }
2026-05-08 12:00:02 +02:00
onExerciseMediaListChanged = { refreshMedia }
2026-04-27 14:48:46 +02:00
/ >
< / 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"
2026-05-08 12:00:02 +02:00
inlineExerciseId = { isEdit ? exerciseId : null }
2026-05-08 12:20:24 +02:00
linkedExerciseMedia = { isEdit ? mediaList : [ ] }
2026-05-08 12:00:02 +02:00
onExerciseMediaListChanged = { refreshMedia }
2026-04-27 14:48:46 +02:00
/ >
< / 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 Pratzen"
/ >
< / div >
2026-04-27 14:27:25 +02:00
2026-04-27 14:48:46 +02:00
< div style = { { display : 'grid' , gridTemplateColumns : '1fr 1fr' , gap : '10px' } } >
2026-04-27 14:27:25 +02:00
< div className = "form-row" >
2026-04-27 14:48:46 +02:00
< label className = "form-label" > Dauer Min < / label >
< input
type = "number"
2026-04-27 14:27:25 +02:00
className = "form-input"
2026-04-27 14:48:46 +02:00
value = { formData . duration _min }
onChange = { ( e ) =>
updateFormField ( 'duration_min' , e . target . value ? parseInt ( e . target . value , 10 ) : '' )
}
2026-04-27 14:27:25 +02:00
/ >
< / div >
< div className = "form-row" >
2026-04-27 14:48:46 +02:00
< label className = "form-label" > Dauer Max < / label >
< input
type = "number"
2026-04-27 14:27:25 +02:00
className = "form-input"
2026-04-27 14:48:46 +02:00
value = { formData . duration _max }
onChange = { ( e ) =>
updateFormField ( 'duration_max' , e . target . value ? parseInt ( e . target . value , 10 ) : '' )
}
2026-04-27 14:27:25 +02:00
/ >
< / div >
2026-04-27 14:48:46 +02:00
< / div >
2026-04-27 14:27:25 +02:00
2026-04-27 14:48:46 +02:00
< div style = { { display : 'grid' , gridTemplateColumns : '1fr 1fr' , gap : '10px' } } >
2026-04-27 14:27:25 +02:00
< div className = "form-row" >
2026-04-27 14:48:46 +02:00
< label className = "form-label" > Gruppe Min < / label >
< input
type = "number"
2026-04-27 14:27:25 +02:00
className = "form-input"
2026-04-27 14:48:46 +02:00
value = { formData . group _size _min }
onChange = { ( e ) =>
updateFormField ( 'group_size_min' , e . target . value ? parseInt ( e . target . value , 10 ) : '' )
}
2026-04-27 14:27:25 +02:00
/ >
< / div >
< div className = "form-row" >
2026-04-27 14:48:46 +02:00
< label className = "form-label" > Gruppe Max < / label >
< input
type = "number"
2026-04-27 14:27:25 +02:00
className = "form-input"
2026-04-27 14:48:46 +02:00
value = { formData . group _size _max }
2026-04-27 14:27:25 +02:00
onChange = { ( e ) =>
2026-04-27 14:48:46 +02:00
updateFormField ( 'group_size_max' , e . target . value ? parseInt ( e . target . value , 10 ) : '' )
2026-04-27 14:27:25 +02:00
}
2026-04-27 14:48:46 +02:00
/ >
2026-04-27 14:27:25 +02:00
< / div >
2026-04-27 14:48:46 +02:00
< / div >
2026-04-27 14:27:25 +02:00
2026-04-27 14:48:46 +02:00
< 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' } } >
2026-04-27 14:27:25 +02:00
< select
className = "form-input"
2026-04-27 14:48:46 +02:00
style = { { flex : '1 1 200px' } }
value = { skillPick }
onChange = { ( e ) => setSkillPick ( e . target . value ) }
2026-04-27 14:27:25 +02:00
>
2026-04-27 14:48:46 +02:00
< option value = "" > Fähigkeit wählen … < / option >
{ availableSkills . map ( ( s ) => (
< option key = { s . id } value = { s . id } >
{ s . name } ( { s . category } )
2026-04-27 14:27:25 +02:00
< / option >
) ) }
< / select >
2026-04-27 14:48:46 +02:00
< button type = "button" className = "btn btn-secondary" onClick = { addSkillRow } >
Hinzufügen
2026-04-27 14:27:25 +02:00
< / button >
< / div >
2026-04-27 14:48:46 +02:00
{ 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 >
2026-04-27 14:27:25 +02:00
< select
className = "form-input"
2026-04-27 14:48:46 +02:00
value = { row . intensity || '' }
onChange = { ( e ) => updateSkillField ( idx , 'intensity' , e . target . value ) }
2026-04-27 14:27:25 +02:00
>
2026-04-27 14:48:46 +02:00
{ INTENSITY _OPTIONS . map ( ( o ) => (
< option key = { o . value || 'i' } value = { o . value } >
{ o . label }
< / option >
) ) }
2026-04-27 14:27:25 +02:00
< / select >
2026-04-27 14:48:46 +02:00
< select
2026-04-27 14:27:25 +02:00
className = "form-input"
2026-04-27 14:48:46 +02:00
value = { row . required _level || '' }
onChange = { ( e ) => updateSkillField ( idx , 'required_level' , e . target . value ) }
>
2026-04-27 18:25:23 +02:00
{ SKILL _LEVEL _OPTIONS . map ( ( o ) => (
2026-04-27 14:48:46 +02:00
< option key = { ` r- ${ o . value } ` } value = { o . value } >
von { o . label }
< / option >
) ) }
< / select >
2026-04-27 14:27:25 +02:00
< select
className = "form-input"
2026-04-27 14:48:46 +02:00
value = { row . target _level || '' }
onChange = { ( e ) => updateSkillField ( idx , 'target_level' , e . target . value ) }
2026-04-27 14:27:25 +02:00
>
2026-04-27 18:25:23 +02:00
{ SKILL _LEVEL _OPTIONS . map ( ( o ) => (
2026-04-27 14:48:46 +02:00
< option key = { ` t- ${ o . value } ` } value = { o . value } >
bis { o . label }
< / option >
) ) }
2026-04-27 14:27:25 +02:00
< / select >
2026-04-27 14:48:46 +02:00
< button type = "button" className = "btn" style = { { fontSize : '12px' , padding : '4px 8px' } } onClick = { ( ) => removeSkillRow ( idx ) } >
Entf .
< / button >
2026-04-27 14:27:25 +02:00
< / div >
2026-04-27 14:48:46 +02:00
)
} ) }
< / div >
2026-04-27 14:27:25 +02:00
2026-04-27 14:48:46 +02:00
< 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 >
2026-05-08 10:20:41 +02:00
{ isSuperadmin ? < option value = "official" > Offiziell < / option > : null }
2026-04-27 14:48:46 +02:00
< / 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 >
2026-04-28 10:59:09 +02:00
{ isEdit && (
2026-04-28 13:09:42 +02:00
< 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 && (
2026-04-28 10:59:09 +02:00
< div className = "form-row" >
2026-04-28 13:09:42 +02:00
< label className = "form-label" htmlFor = "variant-edit-select" >
Variante auswählen
< / label >
2026-04-28 10:59:09 +02:00
< select
2026-04-28 13:09:42 +02:00
id = "variant-edit-select"
2026-04-28 10:59:09 +02:00
className = "form-input"
2026-04-28 13:09:42 +02:00
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 ) )
} }
2026-04-28 10:59:09 +02:00
>
2026-04-28 13:09:42 +02:00
< 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 } ` }
2026-04-28 10:59:09 +02:00
< / option >
) ) }
2026-04-28 13:09:42 +02:00
< option value = "new" > + Neue Variante anlegen … < / option >
2026-04-28 10:59:09 +02:00
< / select >
< / div >
2026-04-28 13:09:42 +02:00
) }
{ 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 }
2026-05-08 12:35:28 +02:00
onPatch = { ( patch ) => {
setFormDirty ( true )
setVariantDraft ( ( d ) => ( { ... d , ... patch } ) )
} }
2026-04-28 13:09:42 +02:00
prerequisiteOthers = { variants }
rteMinHeight = "110px"
2026-05-08 12:00:02 +02:00
inlineExerciseId = { isEdit ? exerciseId : null }
2026-05-08 12:20:24 +02:00
linkedExerciseMedia = { isEdit ? mediaList : [ ] }
2026-05-08 12:00:02 +02:00
onExerciseMediaListChanged = { refreshMedia }
2026-04-28 10:59:09 +02:00
/ >
2026-04-28 13:09:42 +02:00
< 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' ,
} }
2026-04-28 10:59:09 +02:00
>
2026-04-28 13:09:42 +02:00
< 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"
2026-05-08 12:00:02 +02:00
inlineExerciseId = { isEdit ? exerciseId : null }
2026-05-08 12:20:24 +02:00
linkedExerciseMedia = { isEdit ? mediaList : [ ] }
2026-05-08 12:00:02 +02:00
onExerciseMediaListChanged = { refreshMedia }
2026-04-28 13:09:42 +02:00
/ >
2026-04-28 10:59:09 +02:00
< / div >
2026-04-28 13:09:42 +02:00
) }
{ 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 >
2026-04-28 10:59:09 +02:00
) }
2026-04-30 11:47:50 +02:00
{ 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 >
) }
2026-04-27 14:48:46 +02:00
{ isEdit && (
< div className = "card" style = { { marginTop : '16px' } } >
< h2 style = { { marginTop : 0 , fontSize : '1.1rem' } } > Medien < / h2 >
2026-05-08 12:20:24 +02:00
< p style = { { color : 'var(--text2)' , fontSize : '13px' , marginBottom : '6px' } } >
Neue Uploads oder Embeds über die Textfeld - Symbolleiste ( „ Medien im Text “ / „ Embed im Text “ ) . Hier
verwaltest du Verknüpfungen — Kachel in ein Textfeld ziehen , um sie an der Cursorposition einzufügen
( mittlere Darstellung ) .
< / p >
< p style = { { color : 'var(--text3)' , fontSize : '12px' , marginTop : 0 } } >
Max . 10 Medien pro Übung .
2026-04-27 14:48:46 +02:00
< / p >
2026-05-07 13:00:57 +02:00
< div
style = { {
display : 'flex' ,
flexWrap : 'wrap' ,
gap : '8px' ,
alignItems : 'center' ,
marginTop : '10px' ,
} }
>
< button type = "button" className = "btn btn-secondary" onClick = { ( ) => setArchiveOpen ( true ) } >
Aus Archiv verknüpfen …
< / button >
2026-05-07 14:10:26 +02:00
< Link to = "/media" className = "btn btn-secondary" style = { { textDecoration : 'none' } } >
Medienbibliothek
< / Link >
2026-05-07 13:00:57 +02:00
< / div >
2026-04-27 14:48:46 +02:00
{ mediaList . length > 0 && (
2026-05-08 12:20:24 +02:00
< ul className = "exercise-edit-media-strip" >
{ mediaList . map ( ( m , idx ) => {
const cap =
( m . title || '' ) . trim ( ) ||
( m . original _filename || '' ) . trim ( ) ||
( m . embed _url ? String ( m . embed _url ) . replace ( /^https?:\/\//i , '' ) . slice ( 0 , 80 ) : '' )
const sub = [ m . media _type , m . embed _platform ] . filter ( Boolean ) . join ( ' · ' ) || 'Medium'
const payloadCaption = (
[ m . title , m . original _filename ] . find ( ( x ) => typeof x === 'string' && x . trim ( ) ) || ''
) . trim ( )
return (
< li key = { m . id } className = "exercise-edit-media-strip__item" >
< div className = "exercise-edit-media-strip__lead" >
{ ! m . embed _url ? (
< ExerciseMediaThumbTile exerciseId = { exerciseId } media = { m } onOpenPreview = { setMediaPreview } size = { 76 } / >
) : (
< div
className = "exercise-edit-media-strip__embed-badge exercise-edit-media-strip__embed-badge--solo"
aria - hidden
>
{ m . embed _platform || 'Embed' }
< / div >
) }
< div
className = "exercise-edit-media-strip__handle"
title = "Mit Drag und Drop in ein Textfeld ziehen"
draggable
onDragStart = { ( e ) => {
try {
e . dataTransfer . setData (
SHINKAN _EXERCISE _MEDIA _DRAG _MIME ,
buildExerciseMediaDragPayload ( m . id , payloadCaption ) ,
)
e . dataTransfer . effectAllowed = 'copy'
} catch ( _ ) {
/* ignore */
}
} }
>
⣿ < span className = "exercise-edit-media-strip__handle-text" > Ziehen < / span >
< / div >
< / div >
< div className = "exercise-edit-media-strip__body" >
< div className = "exercise-edit-media-strip__headline" >
< span style = { { fontSize : '12px' , color : 'var(--text3)' } } >
# { m . id } · { sub }
< / span >
< / div >
< div style = { { fontSize : '13px' , color : 'var(--text2)' , lineHeight : 1.35 } } > { cap || '—' } < / div >
< div className = "exercise-edit-media-strip__toolbar" >
< input
type = "text"
className = "form-input exercise-edit-media-strip__title"
2026-05-08 12:35:28 +02:00
placeholder = "Titel (wird in der Vorschau und im Platzhalter genutzt)"
2026-05-08 12:20:24 +02:00
value = { ( mediaFields [ m . id ] || { } ) . title ? ? '' }
onChange = { ( e ) =>
setMediaFields ( ( prev ) => ( {
... prev ,
[ m . id ] : {
title : e . target . value ,
} ,
} ) )
}
/ >
< / div >
< div className = "exercise-edit-media-strip__actions" >
{ mediaList . length > 1 && (
< >
< button
type = "button"
className = "btn btn-secondary"
style = { { fontSize : '11px' , padding : '4px 8px' } }
disabled = { idx === 0 }
onClick = { ( ) => moveMediaRow ( idx , - 1 ) }
title = "Nach oben"
>
↑
< / button >
< button
type = "button"
className = "btn btn-secondary"
style = { { fontSize : '11px' , padding : '4px 8px' } }
disabled = { idx >= mediaList . length - 1 }
onClick = { ( ) => moveMediaRow ( idx , 1 ) }
title = "Nach unten"
>
↓
< / button >
< / >
) }
2026-05-07 12:46:59 +02:00
< button
type = "button"
2026-05-08 12:20:24 +02:00
className = "btn btn-primary"
style = { { fontSize : '12px' , padding : '4px 10px' } }
disabled = { mediaSavingId === m . id }
onClick = { ( ) => saveMediaMeta ( m . id ) }
2026-05-07 12:46:59 +02:00
>
2026-05-08 12:20:24 +02:00
{ mediaSavingId === m . id ? '…' : 'Speichern' }
2026-05-07 12:46:59 +02:00
< / button >
< button
type = "button"
className = "btn btn-secondary"
2026-05-08 12:20:24 +02:00
style = { { fontSize : '12px' , padding : '4px 10px' } }
onClick = { ( ) => handleDeleteMedia ( m . id ) }
2026-05-07 12:46:59 +02:00
>
2026-05-08 12:20:24 +02:00
Entfernen
2026-05-07 12:46:59 +02:00
< / button >
2026-05-08 12:20:24 +02:00
< / div >
< / div >
< / li >
)
} ) }
2026-04-27 14:48:46 +02:00
< / ul >
) }
2026-05-08 12:35:28 +02:00
< p style = { { color : 'var(--text3)' , fontSize : '12px' , marginTop : mediaList . length ? '12px' : 0 } } >
Verknüpfungen bleiben nötig ( u . a . Zugriff , Orphan - Hinweise ) : Im Fließtext verweist du gezielt über
Platzhalter . Ohne Verknüpfung gäbe es keine exercise _media - ID zum Einbetten .
< / p >
2026-05-07 13:00:57 +02:00
{ archiveOpen && (
< div
role = "dialog"
aria - modal = "true"
aria - label = "Medienarchiv"
style = { {
position : 'fixed' ,
inset : 0 ,
background : 'rgba(0,0,0,0.5)' ,
zIndex : 1000 ,
overflow : 'auto' ,
padding : '16px' ,
} }
onClick = { ( ) => setArchiveOpen ( false ) }
onKeyDown = { ( e ) => e . key === 'Escape' && setArchiveOpen ( false ) }
>
< div
className = "card"
style = { {
maxWidth : 560 ,
margin : '4vh auto' ,
maxHeight : '88vh' ,
overflow : 'auto' ,
position : 'relative' ,
} }
onClick = { ( e ) => e . stopPropagation ( ) }
>
< h3 style = { { marginTop : 0 , fontSize : '1.05rem' } } > Medienarchiv < / h3 >
< input
type = "search"
className = "form-input"
placeholder = "Suche Dateiname…"
value = { archiveQ }
onChange = { ( e ) => setArchiveQ ( e . target . value ) }
style = { { marginBottom : '8px' } }
/ >
{ archiveLoading && < p style = { { fontSize : '13px' , color : 'var(--text3)' } } > Laden … < / p > }
{ archiveError && < p style = { { fontSize : '13px' , color : 'var(--danger)' } } > { archiveError } < / p > }
{ ! archiveLoading && ! archiveError && archiveItems . length === 0 && (
< p style = { { fontSize : '13px' , color : 'var(--text3)' } } > Keine Treffer . < / p >
) }
< ul style = { { listStyle : 'none' , padding : 0 , margin : 0 } } >
{ archiveItems . map ( ( a ) => {
const already = linkedArchiveAssetIds . has ( a . id )
return (
< li
key = { a . id }
style = { {
display : 'flex' ,
gap : '10px' ,
alignItems : 'center' ,
padding : '8px 0' ,
borderBottom : '1px solid var(--border)' ,
} }
>
< div
style = { {
width : 56 ,
height : 56 ,
flexShrink : 0 ,
borderRadius : '6px' ,
overflow : 'hidden' ,
background : 'var(--surface2, rgba(127,127,127,0.12))' ,
border : '1px solid var(--border)' ,
display : 'flex' ,
alignItems : 'center' ,
justifyContent : 'center' ,
} }
>
{ a . mime _type ? . startsWith ( 'image/' ) ? (
< img
alt = ""
src = { resolveMediaAssetFileUrl ( a . id ) }
style = { { width : '100%' , height : '100%' , objectFit : 'cover' } }
/ >
) : a . mime _type ? . startsWith ( 'video/' ) ? (
< span style = { { fontSize : '18px' , opacity : 0.75 } } aria - hidden >
▶
< / span >
) : (
< span style = { { fontSize : '10px' , color : 'var(--text2)' , padding : '2px' , textAlign : 'center' } } >
PDF
< / span >
) }
< / div >
< div style = { { flex : 1 , minWidth : 0 } } >
< div style = { { fontSize : '13px' , fontWeight : 600 , wordBreak : 'break-word' } } >
{ a . original _filename || ` Asset # ${ a . id } ` }
< / div >
< div style = { { fontSize : '11px' , color : 'var(--text3)' , marginTop : '2px' } } >
{ a . visibility } · { a . mime _type || '—' } { ' ' }
{ a . byte _size != null ? ` · ${ ( a . byte _size / 1024 ) . toFixed ( 0 ) } KB ` : '' }
< / div >
< / div >
< button
type = "button"
className = "btn btn-primary"
style = { { fontSize : '12px' } }
disabled = { already }
title = { already ? 'Schon mit dieser Übung verknüpft' : '' }
onClick = { ( ) => ! already && attachFromArchive ( a . id ) }
>
{ already ? 'Bereits verknüpft' : 'Verknüpfen' }
< / button >
< / li >
)
} ) }
< / ul >
< div style = { { marginTop : '16px' , display : 'flex' , justifyContent : 'flex-end' , gap : '8px' } } >
< button type = "button" className = "btn btn-secondary" onClick = { ( ) => setArchiveOpen ( false ) } >
Schließen
< / button >
< / div >
< / div >
< / div >
) }
{ mediaPreview && (
< div
role = "dialog"
aria - modal = "true"
aria - label = "Medienvorschau"
style = { {
position : 'fixed' ,
inset : 0 ,
background : 'rgba(0,0,0,0.55)' ,
zIndex : 1001 ,
overflow : 'auto' ,
padding : '16px' ,
display : 'flex' ,
alignItems : 'center' ,
justifyContent : 'center' ,
} }
onClick = { ( ) => setMediaPreview ( null ) }
onKeyDown = { ( e ) => e . key === 'Escape' && setMediaPreview ( null ) }
>
< div
className = "card"
style = { { maxWidth : 720 , width : '100%' , maxHeight : '90vh' , overflow : 'auto' } }
onClick = { ( e ) => e . stopPropagation ( ) }
>
< h3 style = { { marginTop : 0 , fontSize : '1.05rem' } } > Vorschau < / h3 >
{ mediaPreview . embed _url ? (
< p style = { { fontSize : '14px' , wordBreak : 'break-all' } } >
< a href = { mediaPreview . embed _url } target = "_blank" rel = "noreferrer" >
{ mediaPreview . embed _url }
< / a >
< / p >
2026-05-07 13:10:37 +02:00
) : mediaPreview . mime _type ? . startsWith ( 'video/' ) || mediaPreview . media _type === 'video' ? (
2026-05-07 13:00:57 +02:00
< video
src = { resolveExerciseMediaFileUrl ( exerciseId , mediaPreview ) }
controls
style = { { width : '100%' , borderRadius : '8px' , maxHeight : '70vh' } }
/ >
2026-05-07 13:10:37 +02:00
) : mediaPreview . mime _type ? . startsWith ( 'image/' ) || mediaPreview . media _type === 'image' ? (
2026-05-07 13:00:57 +02:00
< img
alt = ""
src = { resolveExerciseMediaFileUrl ( exerciseId , mediaPreview ) }
style = { { maxWidth : '100%' , borderRadius : '8px' , maxHeight : '70vh' , objectFit : 'contain' } }
/ >
) : (
< p style = { { fontSize : '14px' } } >
< a href = { resolveExerciseMediaFileUrl ( exerciseId , mediaPreview ) } target = "_blank" rel = "noreferrer" >
Datei öffnen
< / a >
< / p >
) }
< div style = { { marginTop : '16px' } } >
< button type = "button" className = "btn btn-secondary" onClick = { ( ) => setMediaPreview ( null ) } >
Schließen
< / button >
< / div >
< / div >
< / div >
) }
2026-04-27 14:48:46 +02:00
< / div >
) }
< p style = { { fontSize : '12px' , color : 'var(--text3)' , marginTop : '16px' } } >
2026-04-27 15:01:47 +02:00
< strong > KI - Ausbaustufe : < / strong > Backend laut Spec { ' ' }
< code style = { { fontSize : '11px' } } > POST / api / exercises / ai / suggest < / code > und { ' ' }
2026-04-28 10:59:09 +02:00
< code style = { { fontSize : '11px' } } > POST / api / exercises / { '{id}' } / ai / regenerate < / code > — z . B . { ' ' }
2026-04-27 15:01:47 +02:00
< code > OPENROUTER _API _KEY < / code > , Vorschläge nur nach Trainer - Bestätigung übernehmen ( siehe { ' ' }
< code > api . suggestExerciseAi < / code > ) .
2026-04-27 14:48:46 +02:00
< / p >
2026-04-27 14:27:25 +02:00
< / div >
)
}
export default ExerciseFormPage