2026-05-14 16:14:26 +02:00
import React , { useEffect , useState , useRef , useMemo , useCallback } from 'react'
2026-05-20 07:42:46 +02:00
import { useNavigate , useParams , Link , useLocation } from 'react-router-dom'
2026-05-14 16:14:26 +02:00
import api , { buildExerciseApiPayload } from '../../utils/api'
import { resolveExerciseMediaFileUrl , resolveMediaAssetFileUrl } from '../../utils/exerciseMediaUrl'
import RichTextEditor from '../RichTextEditor'
import ExerciseProgressionGraphPanel from '../ExerciseProgressionGraphPanel'
import ExerciseMediaThumbTile from '../ExerciseMediaThumbTile'
import MediaPreviewModal from '../MediaPreviewModal'
import ReportContentModal from '../ReportContentModal'
import CombinationMethodProfileEditor from '../CombinationMethodProfileEditor'
import ExercisePickerModal from '../ExercisePickerModal'
import {
SHINKAN _EXERCISE _MEDIA _DRAG _MIME ,
buildExerciseMediaDragPayload ,
} from '../../utils/exerciseInlineMediaRefs'
import { autoScrollForDragNearEdges } from '../../utils/dragAutoScroll'
2026-05-21 14:40:50 +02:00
import { normalizeSkillLevelSlug } from '../../constants/skillLevels'
import ExerciseCatalogAssocEditor from './ExerciseCatalogAssocEditor'
import ExerciseSkillsEditor from './ExerciseSkillsEditor'
2026-05-14 16:14:26 +02:00
import { useAuth } from '../../context/AuthContext'
import { useToast } from '../../context/ToastContext'
import {
activeClubMemberships ,
getDefaultClubIdForGovernanceForms ,
getTenantClubDependencyKey ,
} from '../../utils/activeClub'
import { COMBINATION _ARCHETYPE _OPTIONS , ARCHETYPE _DEFAULT _REP _SERIES _COUNT , defaultRepSeriesCountForArchetype } from '../../constants/combinationArchetypes'
import { readSlotProfilesV1 , normalizeAdvanceMode , parseComboRepSeriesCountUi } from '../../utils/combinationMethodProfileUi'
import { GripVertical } from 'lucide-react'
import UnsavedChangesPrompt from '../UnsavedChangesPrompt'
2026-05-20 06:38:53 +02:00
import PageFormEditorChrome from '../PageFormEditorChrome'
2026-05-20 07:42:46 +02:00
import { useNavReturn } from '../../hooks/useNavReturn'
import {
EXERCISES _LIST _PATH ,
buildCurrentLocationReturnContext ,
buildExercisesListReturnContext ,
linkStateWithAppReturn ,
preserveAppReturnOnNavigate ,
} from '../../utils/navReturnContext'
2026-05-14 16:14:26 +02:00
import { useBeforeUnloadWhen , useUnsavedChangesBlocker } from '../../hooks/useUnsavedChangesBlocker'
2026-05-21 12:35:45 +02:00
import {
EXERCISE _SKILL _INTENSITY _DEFAULT ,
normalizeExerciseSkillIntensity ,
2026-05-21 12:58:18 +02:00
} from '../../constants/exerciseSkillIntensity'
const VARIANT _DIFFICULTY = [
2026-05-14 16:14:26 +02:00
{ value : '' , label : '—' } ,
{ value : 'easier' , label : 'Einfacher' } ,
{ value : 'same' , label : 'Gleich' } ,
{ value : 'harder' , label : 'Schwerer' } ,
{ value : 'adapted' , label : 'Angepasst' } ,
]
/** HTML5-DnD für Kombi-Stationen (Reihenfolge = Ablauf). */
const DND _EXERCISE _COMBO _STATION = 'application/x-shinkan-exercise-combo-station-v1'
/** Pro Station meist 1 Übung; bis zu 3 wenn kurzer Auswahl-Pool sinnvoll ist. */
const MAX _COMBO _CANDIDATES _PER _STATION = 3
const comboTinyNumberInputSx = {
width : '3.5rem' ,
maxWidth : '100%' ,
padding : '4px 6px' ,
fontSize : '0.8125rem' ,
textAlign : 'center' ,
}
function emptyComboSlotRow ( ) {
return {
title : '' ,
candidate _exercise _ids : [ ] ,
exercise _title _by _id : { } ,
advance _mode : 'timed' ,
load _sec : '' ,
consecutive _reps : '' ,
rep _series _count : '1' ,
intra _rep _rest _sec : '' ,
transition _after _sec : '' ,
}
}
function comboSlotsFromDetail ( exercise ) {
const raw = exercise ? . combination _slots
const arch = exercise ? . method _archetype != null ? String ( exercise . method _archetype ) . trim ( ) : ''
const serienFallback = defaultRepSeriesCountForArchetype ( arch )
const mp =
exercise ? . method _profile &&
typeof exercise . method _profile === 'object' &&
! Array . isArray ( exercise . method _profile )
? exercise . method _profile
: { }
const spvList = readSlotProfilesV1 ( mp )
const byIx = new Map ( spvList . map ( ( r ) => [ Number ( r . slot _index ) , r ] ) )
if ( ! Array . isArray ( raw ) || raw . length === 0 ) {
return [ emptyComboSlotRow ( ) ]
}
const sorted = [ ... raw ] . sort ( ( a , b ) => ( Number ( a . slot _index ) || 0 ) - ( Number ( b . slot _index ) || 0 ) )
return sorted . map ( ( s ) => {
const si = Number ( s . slot _index )
const st = byIx . get ( si ) || { }
const cands = Array . isArray ( s . candidate _exercise _ids )
? s . candidate _exercise _ids . map ( ( x ) => Number ( x ) ) . filter ( ( n ) => Number . isFinite ( n ) )
: [ ]
const mode = normalizeAdvanceMode ( st . advance _mode )
let repSer = ''
if ( st . rep _series _count != null ) repSer = String ( st . rep _series _count )
else if ( mode === 'rep' || mode === 'manual' ) repSer = String ( serienFallback )
else repSer = '1'
return {
title : s . title != null ? String ( s . title ) : '' ,
candidate _exercise _ids : cands ,
exercise _title _by _id : { } ,
advance _mode : mode ,
load _sec : st . load _sec != null ? String ( st . load _sec ) : '' ,
consecutive _reps : st . consecutive _reps != null ? String ( st . consecutive _reps ) : '' ,
rep _series _count : repSer ,
intra _rep _rest _sec : st . intra _rep _rest _sec != null ? String ( st . intra _rep _rest _sec ) : '' ,
transition _after _sec : st . transition _after _sec != null ? String ( st . transition _after _sec ) : '' ,
}
} )
}
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' ,
inlineExerciseId ,
linkedExerciseMedia = [ ] ,
onExerciseMediaListChanged ,
} ) {
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 }
inlineExerciseId = { inlineExerciseId }
linkedExerciseMedia = { linkedExerciseMedia }
onExerciseMediaListChanged = { onExerciseMediaListChanged }
/ >
< / 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 >
< / >
)
}
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' ,
club _id : null ,
status : 'draft' ,
skills : [ ] ,
exercise _kind : 'simple' ,
method _archetype : '' ,
method _profile _json : '{}' ,
combination _slots : [ emptyComboSlotRow ( ) ] ,
}
}
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' ,
club _id :
String ( exercise . visibility || '' ) . trim ( ) . toLowerCase ( ) === 'club' &&
exercise . club _id != null &&
exercise . club _id !== ''
? Number ( exercise . club _id )
: null ,
status : exercise . status || 'draft' ,
skills :
exercise . skills ? . map ( ( s ) => ( {
skill _id : s . skill _id ,
2026-05-21 12:35:45 +02:00
intensity : normalizeExerciseSkillIntensity ( s . intensity ) ,
2026-05-14 16:14:26 +02:00
required _level : normalizeSkillLevelSlug ( s . required _level ) ,
target _level : normalizeSkillLevelSlug ( s . target _level ) ,
} ) ) || [ ] ,
exercise _kind :
String ( exercise . exercise _kind || 'simple' ) . toLowerCase ( ) === 'combination'
? 'combination'
: 'simple' ,
method _archetype : exercise . method _archetype != null ? String ( exercise . method _archetype ) : '' ,
method _profile _json :
typeof exercise . method _profile === 'object' &&
exercise . method _profile != null &&
! Array . isArray ( exercise . method _profile )
? JSON . stringify ( exercise . method _profile , null , 2 )
: '{}' ,
combination _slots : comboSlotsFromDetail ( exercise ) ,
}
}
function ExerciseFormPageRoot ( ) {
const { id : routeId } = useParams ( )
const navigate = useNavigate ( )
2026-05-20 07:42:46 +02:00
const location = useLocation ( )
const exercisesListReturn = useMemo ( ( ) => buildExercisesListReturnContext ( ) , [ ] )
const { goBack } = useNavReturn ( exercisesListReturn )
2026-05-14 16:14:26 +02:00
const { user } = useAuth ( )
const isSuperadmin = user ? . role === 'superadmin'
const isPlatformAdmin = user ? . role === 'admin' || user ? . role === 'superadmin'
const tenantClubDepKey = useMemo ( ( ) => getTenantClubDependencyKey ( user ) , [ user ] )
const [ clubsForGovernanceForms , setClubsForGovernanceForms ] = useState ( [ ] )
useEffect ( ( ) => {
if ( ! isPlatformAdmin ) {
setClubsForGovernanceForms ( [ ] )
return undefined
}
let cancelled = false
; ( async ( ) => {
try {
const list = await api . listClubs ( )
if ( ! cancelled ) setClubsForGovernanceForms ( Array . isArray ( list ) ? list : [ ] )
} catch {
if ( ! cancelled ) setClubsForGovernanceForms ( [ ] )
}
} ) ( )
return ( ) => {
cancelled = true
}
} , [ isPlatformAdmin , tenantClubDepKey ] )
const membershipClubRows = useMemo ( ( ) => activeClubMemberships ( user ? . clubs ) , [ user ? . clubs ] )
/** Plattform-Admin: alle Vereine; sonst nur Mitgliedschafts-Vereine. */
const visibilityClubChoices = useMemo ( ( ) => {
if ( isPlatformAdmin && clubsForGovernanceForms . length > 0 ) {
return [ ... clubsForGovernanceForms ] . sort ( ( a , b ) =>
String ( a . name || '' ) . localeCompare ( String ( b . name || '' ) , 'de' ) ,
)
}
return [ ... membershipClubRows ] . sort ( ( a , b ) =>
String ( a . name || '' ) . localeCompare ( String ( b . name || '' ) , 'de' ) ,
)
} , [ isPlatformAdmin , clubsForGovernanceForms , membershipClubRows ] )
const governanceDefaultClubId = useMemo ( ( ) => getDefaultClubIdForGovernanceForms ( user ) , [ user ] )
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 [ formDirty , setFormDirty ] = useState ( false )
const [ skillPick , setSkillPick ] = useState ( '' )
const toast = useToast ( )
const allowUnloadBlock = Boolean ( formDirty && ! loading && ! saving )
useBeforeUnloadWhen ( allowUnloadBlock )
const blocker = useUnsavedChangesBlocker ( allowUnloadBlock )
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 [ mediaFields , setMediaFields ] = useState ( { } )
const [ mediaSavingId , setMediaSavingId ] = useState ( null )
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 )
const [ reportTarget , setReportTarget ] = useState ( null )
useEffect ( ( ) => {
const next = { }
for ( const m of mediaList ) {
next [ m . id ] = {
title : m . title || '' ,
}
}
setMediaFields ( next )
} , [ mediaList ] )
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 ( ! 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 || [ ] ) . filter ( a => ! a . legal _hold _active ) )
} 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 ( [
2026-05-20 10:59:17 +02:00
api . listSkillsCatalog ( ) ,
2026-05-14 16:14:26 +02:00
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 )
toast . error (
'Kataloge (Fokus, Stile, Zielgruppen, Fähigkeiten) konnten nicht geladen werden: ' +
( e . message || e ) ,
)
}
}
}
boot ( )
return ( ) => {
cancelled = true
}
} , [ toast ] )
useEffect ( ( ) => {
if ( ! isEdit ) {
setFormData ( emptyForm ( ) )
setMediaList ( [ ] )
setVariants ( [ ] )
setVariantDraft ( emptyVariantDraft ( ) )
setVariantEditSelection ( null )
setFormDirty ( false )
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 )
setFormDirty ( false )
} catch ( err ) {
if ( ! cancelled ) {
toast . error ( err . message || 'Übung nicht ladbar' )
navigate ( '/exercises' )
}
} finally {
if ( ! cancelled ) setLoading ( false )
}
}
load ( )
return ( ) => {
cancelled = true
}
} , [ isEdit , exerciseId , navigate , toast ] )
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 ) => {
setFormDirty ( true )
setFormData ( ( prev ) => ( { ... prev , [ field ] : value } ) )
}
useEffect ( ( ) => {
if ( formData . visibility !== 'club' ) return
const choices = visibilityClubChoices
if ( ! choices . length ) return
const id =
formData . club _id != null && formData . club _id !== '' ? Number ( formData . club _id ) : NaN
const hasValid =
Number . isFinite ( id ) && id > 0 && choices . some ( ( c ) => Number ( c . id ) === id )
if ( hasValid ) return
const fallback = governanceDefaultClubId
const next =
fallback != null &&
Number . isFinite ( Number ( fallback ) ) &&
choices . some ( ( c ) => Number ( c . id ) === Number ( fallback ) )
? Number ( fallback )
: Number ( choices [ 0 ] . id )
setFormData ( ( prev ) => {
if ( prev . visibility !== 'club' ) return prev
if ( prev . club _id != null && Number ( prev . club _id ) === next ) return prev
return { ... prev , club _id : next }
} )
} , [
formData . visibility ,
formData . club _id ,
visibilityClubChoices ,
governanceDefaultClubId ,
] )
const [ comboStationPickerIx , setComboStationPickerIx ] = useState ( null )
const [ comboDropTargetIx , setComboDropTargetIx ] = useState ( null )
const reorderCombinationSlots = ( fromI , toBeforeIx ) => {
setFormDirty ( true )
setFormData ( ( prev ) => {
const rows = [ ... ( prev . combination _slots || [ ] ) ]
if ( fromI < 0 || fromI >= rows . length ) return prev
const [ moved ] = rows . splice ( fromI , 1 )
let insertAt = toBeforeIx
if ( fromI < toBeforeIx ) insertAt = toBeforeIx - 1
insertAt = Math . max ( 0 , Math . min ( insertAt , rows . length ) )
rows . splice ( insertAt , 0 , moved )
return { ... prev , combination _slots : rows }
} )
}
const patchComboSlotRow = ( idx , patch ) => {
setFormDirty ( true )
setFormData ( ( prev ) => {
const rows = [ ... ( prev . combination _slots || [ ] ) ]
if ( ! rows [ idx ] ) return prev
rows [ idx ] = { ... rows [ idx ] , ... patch }
return { ... prev , combination _slots : rows }
} )
}
const removeCandidateFromSlot = ( slotIdx , exerciseId ) => {
setFormDirty ( true )
setFormData ( ( prev ) => {
const rows = [ ... ( prev . combination _slots || [ ] ) ]
const row = rows [ slotIdx ]
if ( ! row ) return prev
const nextIds = ( row . candidate _exercise _ids || [ ] ) . filter ( ( id ) => Number ( id ) !== Number ( exerciseId ) )
const labels = row . exercise _title _by _id && typeof row . exercise _title _by _id === 'object' ? { ... row . exercise _title _by _id } : { }
delete labels [ Number ( exerciseId ) ]
rows [ slotIdx ] = { ... row , candidate _exercise _ids : nextIds , exercise _title _by _id : labels }
return { ... prev , combination _slots : rows }
} )
}
const mergePickedExercisesIntoSlot = ( slotIdx , pickedList ) => {
if ( ! Array . isArray ( pickedList ) || ! pickedList . length ) return
const rowNow = ( formData . combination _slots || [ ] ) [ slotIdx ] || emptyComboSlotRow ( )
const existingIds = Array . isArray ( rowNow . candidate _exercise _ids )
? rowNow . candidate _exercise _ids . map ( ( n ) => Number ( n ) ) . filter ( ( n ) => Number . isFinite ( n ) )
: [ ]
const ordered = [ ... existingIds ]
pickedList . forEach ( ( ex ) => {
if ( ex ? . id == null ) return
const id = Number ( ex . id )
if ( ! Number . isFinite ( id ) ) return
if ( ! ordered . includes ( id ) ) ordered . push ( id )
} )
let nextIds = ordered
if ( nextIds . length > MAX _COMBO _CANDIDATES _PER _STATION ) {
toast . info (
` Pro Station höchstens ${ MAX _COMBO _CANDIDATES _PER _STATION } Übungen — üblich eine feste Übung; zwei bis drei nur als kleiner Wechsel‑ Pool. Überschüssige Auswahl wurde abgeschnitten. ` ,
)
nextIds = nextIds . slice ( 0 , MAX _COMBO _CANDIDATES _PER _STATION )
}
setFormDirty ( true )
setFormData ( ( prev ) => {
const rows = [ ... ( prev . combination _slots || [ ] ) ]
const row = rows [ slotIdx ] || emptyComboSlotRow ( )
const labels =
row . exercise _title _by _id && typeof row . exercise _title _by _id === 'object' ? { ... row . exercise _title _by _id } : { }
pickedList . forEach ( ( ex ) => {
if ( ex && ex . id != null ) {
const id = Number ( ex . id )
const t = ( ex . title || '' ) . trim ( )
if ( t ) labels [ id ] = t
}
} )
rows [ slotIdx ] = { ... row , candidate _exercise _ids : nextIds , exercise _title _by _id : labels }
return { ... prev , combination _slots : rows }
} )
}
const addSkillRow = ( ) => {
const id = skillPick ? parseInt ( skillPick , 10 ) : null
if ( ! id ) {
toast . error ( 'Fähigkeit wählen' )
return
}
if ( formData . skills . some ( ( s ) => s . skill _id === id ) ) {
toast . info ( 'Bereits zugeordnet' )
return
}
updateFormField ( 'skills' , [
... formData . skills ,
{
skill _id : id ,
2026-05-21 12:35:45 +02:00
intensity : EXERCISE _SKILL _INTENSITY _DEFAULT ,
2026-05-14 16:14:26 +02:00
required _level : '' ,
target _level : '' ,
} ,
] )
setSkillPick ( '' )
}
2026-05-21 12:35:45 +02:00
const removeSkillRow = ( idx ) => {
2026-05-14 16:14:26 +02:00
updateFormField (
'skills' ,
2026-05-21 12:35:45 +02:00
formData . skills . filter ( ( _ , i ) => i !== idx ) ,
2026-05-14 16:14:26 +02:00
)
}
const updateSkillField = ( idx , field , value ) => {
updateFormField (
'skills' ,
formData . skills . map ( ( s , i ) => ( i === idx ? { ... s , [ field ] : value } : s ) ) ,
)
}
const performSaveAttempt = useCallback (
2026-05-20 06:38:53 +02:00
async ( { fromUnsavedDialog = false , closeAfter = false } = { } ) => {
2026-05-14 16:14:26 +02:00
if ( ! formData . title || formData . title . trim ( ) . length < 3 ) {
toast . error ( 'Titel mindestens 3 Zeichen' )
return false
}
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 ) {
toast . error ( err . message )
return false
}
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
) {
toast . error (
'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 ) {
toast . error ( '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
) {
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):' ,
'© ' ,
)
if ( ! defaultCopyright || String ( defaultCopyright ) . trim ( ) . length < 3 ) {
toast . error ( 'Mindestens 3 Zeichen für den Copyright-Vermerk.' )
throw firstErr
}
await saveOnce ( {
default _club _media _copyright : String ( defaultCopyright ) . trim ( ) ,
} )
} else if ( firstErr . status === 422 && firstErr . code === 'CLUB_MEDIA_LIFECYCLE' ) {
toast . error (
'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 ) )
setFormDirty ( false )
toast . success ( 'Gespeichert.' )
2026-05-20 07:42:46 +02:00
if ( closeAfter ) goBack ( )
2026-05-14 16:14:26 +02:00
return true
}
const created = await api . createExercise ( payload )
setFormDirty ( false )
toast . success ( 'Übung angelegt.' )
2026-05-20 06:38:53 +02:00
if ( closeAfter ) {
2026-05-20 07:42:46 +02:00
goBack ( )
2026-05-20 06:38:53 +02:00
} else if ( ! fromUnsavedDialog ) {
2026-05-20 07:42:46 +02:00
preserveAppReturnOnNavigate ( navigate , location , ` /exercises/ ${ created . id } /edit ` , { replace : true } )
2026-05-14 16:14:26 +02:00
}
return true
} catch ( err ) {
toast . error ( 'Fehler beim Speichern: ' + err . message )
return false
} finally {
setSaving ( false )
}
} ,
2026-05-20 07:42:46 +02:00
[ exerciseId , formData , isEdit , navigate , location , toast , goBack ] ,
2026-05-14 16:14:26 +02:00
)
2026-05-20 06:38:53 +02:00
const handleSubmit = useCallback (
async ( e ) => {
e ? . preventDefault ? . ( )
await performSaveAttempt ( { fromUnsavedDialog : false , closeAfter : false } )
} ,
[ performSaveAttempt ] ,
)
const handleSaveAndClose = useCallback (
async ( e ) => {
e ? . preventDefault ? . ( )
await performSaveAttempt ( { fromUnsavedDialog : false , closeAfter : true } )
} ,
[ performSaveAttempt ] ,
)
const actionConfig = useMemo (
( ) => ( {
formId : 'exercise-form' ,
saving ,
isNew : ! isEdit ,
onSave : handleSubmit ,
onSaveAndClose : handleSaveAndClose ,
2026-05-20 07:42:46 +02:00
onCancel : goBack ,
2026-05-20 06:38:53 +02:00
showSave : true ,
showSaveAndClose : true ,
} ) ,
2026-05-20 07:42:46 +02:00
[ saving , isEdit , handleSubmit , handleSaveAndClose , goBack ] ,
2026-05-20 06:38:53 +02:00
)
2026-05-14 16:14:26 +02:00
const handleUnsavedDialogSave = async ( ) => {
const ok = await performSaveAttempt ( { fromUnsavedDialog : true } )
if ( ok ) blocker . proceed ( )
}
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 : 'ablauf' ,
title : '' ,
description : '' ,
is _primary : false ,
} )
setArchiveOpen ( false )
await refreshMedia ( )
} catch ( e ) {
toast . error ( e . message || String ( e ) )
}
}
const linkedArchiveAssetIds = useMemo (
( ) => new Set ( ( mediaList || [ ] ) . map ( ( m ) => m . media _asset _id ) . filter ( Boolean ) ) ,
[ mediaList ] ,
)
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.\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.' ,
)
) {
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 ) {
toast . error ( 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 ) {
toast . error ( 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 ) {
toast . error ( 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 ) => {
setFormDirty ( true )
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 ) {
toast . error ( 'Variantenname mindestens 3 Zeichen' )
return
}
setVariantSavingId ( row . id )
try {
await api . updateExerciseVariant ( exerciseId , row . id , payload )
await refreshVariants ( )
} catch ( e ) {
toast . error ( 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 ) {
toast . error ( 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 ) {
toast . error ( 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 ) {
toast . error ( '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 ) {
toast . error ( err . message || String ( err ) )
} finally {
setVariantBusy ( false )
}
}
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 (
2026-05-20 06:38:53 +02:00
< PageFormEditorChrome
testId = "exercise-form-page"
title = { isEdit ? 'Übung bearbeiten' : 'Neue Übung' }
2026-05-20 07:42:46 +02:00
fallbackPath = { EXERCISES _LIST _PATH }
fallbackLabel = "Zurück zur Übungsliste"
2026-05-20 06:38:53 +02:00
actionConfig = { actionConfig }
>
{ isEdit ? (
< p style = { { margin : '0 0 12px' } } >
< Link
to = { ` /exercises/ ${ exerciseId } ` }
2026-05-20 07:42:46 +02:00
state = { linkStateWithAppReturn (
buildCurrentLocationReturnContext ( location , 'Zurück zur Bearbeitung' )
) }
2026-05-14 16:14:26 +02:00
className = "btn btn-secondary"
2026-05-20 06:38:53 +02:00
style = { { fontSize : '0.875rem' } }
2026-05-14 16:14:26 +02:00
>
Ansehen
2026-05-20 06:38:53 +02:00
< / Link >
< / p >
) : null }
2026-05-14 16:14:26 +02:00
< div className = "card" >
2026-05-20 06:38:53 +02:00
< form id = "exercise-form" onSubmit = { handleSubmit } >
2026-05-14 16:14:26 +02:00
< 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"
inlineExerciseId = { isEdit ? exerciseId : null }
linkedExerciseMedia = { isEdit ? mediaList : [ ] }
onExerciseMediaListChanged = { refreshMedia }
/ >
< / div >
< div
style = { {
padding : '12px' ,
borderRadius : '8px' ,
border : '1px solid var(--border)' ,
background : 'var(--surface2)' ,
marginBottom : '12px' ,
} }
>
< h3 style = { { marginTop : 0 , marginBottom : '10px' , fontSize : '1rem' } } > Übungstyp < / h3 >
< div className = "form-row" >
< label className = "form-label" > Art < / label >
< select
className = "form-input"
value = { formData . exercise _kind === 'combination' ? 'combination' : 'simple' }
onChange = { ( e ) => {
const nk = e . target . value
setFormDirty ( true )
setFormData ( ( prev ) => ( {
... prev ,
exercise _kind : nk ,
... ( nk === 'simple'
? {
method _archetype : '' ,
method _profile _json : '{}' ,
combination _slots : [ emptyComboSlotRow ( ) ] ,
}
: { } ) ,
} ) )
} }
>
< option value = "simple" > Einzelübung < / option >
< option value = "combination" > Kombinationsübung ( Stationen / Pool ) < / option >
< / select >
< / div >
{ formData . exercise _kind === 'combination' ? (
< >
< div className = "form-row" >
< label className = "form-label" > Methoden - Archetyp ( für Coach & amp ; Planung empfohlen ) < / label >
< select
className = "form-input"
value = { formData . method _archetype || '' }
onChange = { ( e ) => {
const arch = ( e . target . value || '' ) . trim ( )
const forced = ARCHETYPE _DEFAULT _REP _SERIES _COUNT [ arch ]
setFormDirty ( true )
setFormData ( ( prev ) => {
const slots = prev . combination _slots || [ ]
const nextSlots =
forced !== undefined && forced !== null
? slots . map ( ( row ) =>
normalizeAdvanceMode ( row . advance _mode ) !== 'timed'
? {
... row ,
rep _series _count : String ( Math . max ( 1 , Math . round ( Number ( forced ) ) ) ) ,
}
: row ,
)
: slots
return { ... prev , method _archetype : arch , combination _slots : nextSlots }
} )
} }
>
< option value = "" > — noch nicht festgelegt — < / option >
{ COMBINATION _ARCHETYPE _OPTIONS . map ( ( o ) => (
< option key = { o . id } value = { o . id } >
{ o . label }
< / option >
) ) }
< / select >
< / div >
{ String ( formData . method _archetype || '' ) . trim ( ) === 'station_parcour' ? (
< p
style = { {
fontSize : '12px' ,
color : 'var(--text2)' ,
margin : '4px 0 10px' ,
lineHeight : 1.48 ,
padding : '10px 12px' ,
borderRadius : '8px' ,
background : 'var(--surface)' ,
border : '1px solid var(--border)' ,
} }
>
< strong > Parcours / Bahnsystem : < / strong > typischerweise starten alle an Station 1 und durchlaufen der
Reihe nach alle Punkte ( Geschwindigkeit variabel ) . Die Stationsreihenfolge unten ist der Ablaufweg ;
Zeitangaben pro Station und < strong > Gesamtdurchläufe < / strong > im Ablaufprofil strukturieren das
spätere Coaching .
< / p >
) : null }
< div style = { { paddingTop : '4px' , borderTop : '1px dashed var(--border)' , marginBottom : '12px' } } >
< div style = { { display : 'flex' , justifyContent : 'space-between' , alignItems : 'baseline' , gap : '8px' , flexWrap : 'wrap' } } >
< strong style = { { fontSize : '14px' } } > Stationen < / strong >
< span style = { { fontSize : '11px' , color : 'var(--text3)' } } >
Ablauf = Reihenfolge · ziehen / Pfeile · Einzelübungen · max . { MAX _COMBO _CANDIDATES _PER _STATION } / Station
< / span >
< / div >
< p style = { { fontSize : '12px' , color : 'var(--text2)' , margin : '6px 0 12px' , lineHeight : 1.48 } } >
Pro Station oft < strong > eine < / strong > feste Übung ; höchstens < strong > drei < / strong > als kleiner Auswahl ‑ Pool .
Unter < strong > Steuerung < / strong > wählen : zeitlich , nach Wiederholungszahl oder ohne Arbeitsuhr ( Coach führt ) .
< / p >
{ ( formData . combination _slots || [ ] ) . map ( ( row , idx ) => {
const candIds = Array . isArray ( row . candidate _exercise _ids ) ? row . candidate _exercise _ids : [ ]
const comboPoolFull = candIds . length >= MAX _COMBO _CANDIDATES _PER _STATION
const slotAdv = normalizeAdvanceMode ( row . advance _mode )
const serieLabel =
slotAdv === 'timed' ? 'Serie' : slotAdv === 'rep' ? 'Wdh. / Serie' : 'Richtwert'
const seriePlaceholder = slotAdv === 'rep' ? '10' : slotAdv === 'manual' ? '– ' : '1'
const showMultiSeries = slotAdv === 'rep' || slotAdv === 'manual'
const serienCountUi = parseComboRepSeriesCountUi ( row . rep _series _count )
const showInterSeriesPause = showMultiSeries && serienCountUi >= 2
const intraLabel = slotAdv === 'timed' ? 'Pause (s)' : 'Pause zw. Serien'
const lbl =
row . exercise _title _by _id && typeof row . exercise _title _by _id === 'object'
? row . exercise _title _by _id
: { }
const isDropHere = comboDropTargetIx === idx
return (
< div
key = { ` combo-slot- ${ idx } ` }
onDragOver = { ( e ) => {
if ( ! e . dataTransfer ? . types ? . includes ? . ( DND _EXERCISE _COMBO _STATION ) ) return
e . preventDefault ( )
e . dataTransfer . dropEffect = 'move'
setComboDropTargetIx ( idx )
} }
onDragLeave = { ( ) => setComboDropTargetIx ( ( cur ) => ( cur === idx ? null : cur ) ) }
onDrop = { ( e ) => {
const rawFrom = e . dataTransfer . getData ( DND _EXERCISE _COMBO _STATION )
const fromI = parseInt ( rawFrom , 10 )
e . preventDefault ( )
setComboDropTargetIx ( null )
if ( ! Number . isFinite ( fromI ) ) return
reorderCombinationSlots ( fromI , idx )
} }
style = { {
marginBottom : '12px' ,
padding : '12px 14px' ,
borderRadius : '12px' ,
border : ` 1px solid ${ isDropHere ? 'var(--accent)' : 'var(--border)' } ` ,
background : 'var(--surface)' ,
boxShadow : isDropHere ? '0 0 0 2px var(--accent-soft)' : 'none' ,
} }
>
< div style = { { display : 'flex' , flexWrap : 'wrap' , gap : '10px' , alignItems : 'flex-start' , marginBottom : '12px' } } >
< button
type = "button"
draggable
onDragStart = { ( e ) => {
e . dataTransfer . effectAllowed = 'move'
e . dataTransfer . setData ( DND _EXERCISE _COMBO _STATION , String ( idx ) )
} }
onDragEnd = { ( ) => setComboDropTargetIx ( null ) }
aria - label = { ` Station ${ idx + 1 } ziehen ` }
title = "Ziehen zum Sortieren"
className = "btn btn-secondary framework-ctrl framework-ctrl--xs"
style = { { cursor : 'grab' , padding : '6px 8px' , touchAction : 'none' } }
>
< GripVertical size = { 18 } strokeWidth = { 2 } aria - hidden / >
< / button >
< div style = { { display : 'flex' , flexDirection : 'column' , gap : '4px' } } >
< button
type = "button"
className = "btn btn-secondary framework-ctrl framework-ctrl--xs"
aria - label = "Station nach oben"
disabled = { idx === 0 }
onClick = { ( ) => reorderCombinationSlots ( idx , idx - 1 ) }
>
▲
< / button >
< button
type = "button"
className = "btn btn-secondary framework-ctrl framework-ctrl--xs"
aria - label = "Station nach unten"
disabled = { idx === ( formData . combination _slots || [ ] ) . length - 1 }
onClick = { ( ) => reorderCombinationSlots ( idx , idx + 2 ) }
>
▼
< / button >
< / div >
< div className = "form-row" style = { { flex : '1 1 200px' , marginBottom : 0 } } >
< label className = "form-label" style = { { fontSize : '12px' } } >
Name ( St . & nbsp ; { idx + 1 } )
< / label >
< input
type = "text"
className = "form-input"
value = { row . title || '' }
placeholder = "z. B. Liegestütz"
onChange = { ( e ) => patchComboSlotRow ( idx , { title : e . target . value } ) }
/ >
< / div >
< div style = { { display : 'flex' , flexWrap : 'wrap' , gap : '8px' , alignItems : 'flex-end' } } >
< button
type = "button"
className = "btn btn-secondary framework-ctrl framework-ctrl--xs"
disabled = { comboPoolFull }
title = {
comboPoolFull
? ` Max. ${ MAX _COMBO _CANDIDATES _PER _STATION } Übungen — eine entfernen, um weitere zu wählen. `
: 'Einzelübung zur Station hinzufügen'
}
onClick = { ( ) => setComboStationPickerIx ( idx ) }
>
+ Übung
< / button >
< button
type = "button"
className = "btn framework-ctrl framework-ctrl--xs"
style = { { fontSize : '12px' } }
title = "Diese Station entfernen"
onClick = { ( ) => {
const prev = formData . combination _slots || [ ]
const next = prev . filter ( ( _ , j ) => j !== idx )
updateFormField ( 'combination_slots' , next . length ? next : [ emptyComboSlotRow ( ) ] )
} }
>
Entfernen
< / button >
< / div >
< / div >
< div style = { { marginBottom : '12px' } } >
< span className = "form-label" style = { { fontSize : '11px' , display : 'block' , marginBottom : '6px' } } >
Übungen ( { candIds . length } / { MAX _COMBO _CANDIDATES _PER _STATION } )
< / span >
{ candIds . length === 0 ? (
< p style = { { fontSize : '12px' , color : 'var(--text3)' , margin : 0 } } >
Mindestens eine Übung — mit „ + Übung “ wählen .
< / p >
) : (
< ul style = { { listStyle : 'none' , padding : 0 , margin : 0 , display : 'flex' , flexWrap : 'wrap' , gap : '6px' } } >
{ candIds . map ( ( id ) => (
< li
key = { ` ${ idx } -c- ${ id } ` }
style = { {
display : 'inline-flex' ,
alignItems : 'center' ,
gap : '6px' ,
padding : '4px 10px' ,
borderRadius : '999px' ,
border : '1px solid var(--border)' ,
background : 'var(--surface2)' ,
fontSize : '12px' ,
} }
>
< span style = { { overflow : 'hidden' , textOverflow : 'ellipsis' , maxWidth : '14rem' } } title = { ` # ${ id } ` } >
{ ( lbl [ id ] || lbl [ String ( id ) ] || '' ) . trim ( ) || ` Übung # ${ id } ` }
< / span >
< button
type = "button"
className = "tu-icon-btn"
aria - label = { ` Übung ${ id } entfernen ` }
title = "Entfernen"
onClick = { ( ) => removeCandidateFromSlot ( idx , id ) }
>
✗
< / button >
< / li >
) ) }
< / ul >
) }
< / div >
< div className = "form-row" style = { { marginBottom : '8px' , maxWidth : '22rem' } } >
< label className = "form-label" style = { { fontSize : '11px' } } >
Steuerung
< / label >
< select
className = "form-input"
style = { { fontSize : '0.8125rem' } }
value = { slotAdv }
onChange = { ( e ) => {
const m = normalizeAdvanceMode ( e . target . value )
const patch = { advance _mode : m }
if ( m !== 'timed' ) patch . load _sec = ''
if ( m === 'rep' || m === 'manual' ) {
const curSer = String ( row . rep _series _count ? ? '' ) . trim ( )
if ( ! curSer ) {
patch . rep _series _count = String (
defaultRepSeriesCountForArchetype ( formData . method _archetype || '' ) ,
)
}
}
patchComboSlotRow ( idx , patch )
} }
>
< option value = "timed" > Zeit ( Arbeit in Sekunden ) < / option >
< option value = "rep" > Wiederholungen ( Ziel ) < / option >
< option value = "manual" > Coach ( Weiter nach Freigabe ) < / option >
< / select >
< / div >
< p style = { { fontSize : '11px' , color : 'var(--text3)' , margin : '0 0 10px' , lineHeight : 1.42 } } >
{ slotAdv === 'timed'
? 'Arbeit (s): geplantes Ende nach Countdown möglich. Serie: Wiederholungen ohne Stationswechsel innerhalb einer Phase.'
: slotAdv === 'rep'
? 'Ohne Pflicht-Arbeits-Timer: Ziel über Wiederholungen. Ab zwei Serien: Pause zwischen diesen Serien; sonst nur Wechsel zur nächsten Station.'
: 'Coach: keine feste Arbeitsuhr — Fortschritt später per Tipp. Ab 2 Serien: Pause zwischen Serien; sonst nur Wechsel zur nächsten Station zeitlich planen.' }
< / p >
< div
style = { {
display : 'grid' ,
gridTemplateColumns : 'repeat(auto-fill, minmax(5.25rem, 1fr))' ,
gap : '8px 10px' ,
alignItems : 'end' ,
} }
>
{ slotAdv === 'timed' ? (
< div className = "form-row" style = { { marginBottom : 0 } } >
< label className = "form-label" style = { { fontSize : '10px' } } >
Arbeit ( s )
< / label >
< input
type = "number"
min = { 0 }
className = "form-input"
style = { comboTinyNumberInputSx }
placeholder = "– "
value = { row . load _sec || '' }
onChange = { ( e ) => patchComboSlotRow ( idx , { load _sec : e . target . value } ) }
/ >
< / div >
) : null }
< div className = "form-row" style = { { marginBottom : 0 } } >
< label className = "form-label" style = { { fontSize : '10px' } } >
{ serieLabel }
< / label >
< input
type = "number"
min = { slotAdv === 'rep' ? 1 : undefined }
className = "form-input"
style = { comboTinyNumberInputSx }
placeholder = { seriePlaceholder }
value = { row . consecutive _reps || '' }
onChange = { ( e ) => patchComboSlotRow ( idx , { consecutive _reps : e . target . value } ) }
/ >
< / div >
{ showMultiSeries ? (
< div className = "form-row" style = { { marginBottom : 0 } } >
< label className = "form-label" style = { { fontSize : '10px' } } title = "Wie oft die angegebene Wdh.-Zahl hintereinander (mit Pause zw. Serien)?" >
Serien
< / label >
< input
type = "number"
min = { slotAdv === 'rep' ? 1 : undefined }
className = "form-input"
style = { comboTinyNumberInputSx }
placeholder = "1"
value = { row . rep _series _count || '' }
onChange = { ( e ) => {
let rawSer = e . target . value . trim ( )
if ( rawSer === '' ) rawSer = '1'
const pn = parseInt ( String ( rawSer ) . trim ( ) , 10 )
const patch = { rep _series _count : rawSer }
if ( ! Number . isFinite ( pn ) || pn < 2 ) patch . intra _rep _rest _sec = ''
patchComboSlotRow ( idx , patch )
} }
/ >
< / div >
) : null }
{ slotAdv === 'timed' || showInterSeriesPause ? (
< div className = "form-row" style = { { marginBottom : 0 } } >
< label className = "form-label" style = { { fontSize : '10px' } } >
{ intraLabel }
< / label >
< input
type = "number"
min = { 0 }
className = "form-input"
style = { comboTinyNumberInputSx }
placeholder = "– "
value = { row . intra _rep _rest _sec || '' }
onChange = { ( e ) => patchComboSlotRow ( idx , { intra _rep _rest _sec : e . target . value } ) }
/ >
< / div >
) : null }
< div className = "form-row" style = { { marginBottom : 0 } } >
< label className = "form-label" style = { { fontSize : '10px' } } >
Wechsel ( s )
< / label >
< input
type = "number"
min = { 0 }
className = "form-input"
style = { comboTinyNumberInputSx }
placeholder = "– "
value = { row . transition _after _sec || '' }
onChange = { ( e ) => patchComboSlotRow ( idx , { transition _after _sec : e . target . value } ) }
/ >
< / div >
< / div >
{ showMultiSeries && serienCountUi < 2 ? (
< p style = { { fontSize : '10px' , color : 'var(--text3)' , margin : '6px 0 0' , lineHeight : 1.38 } } >
< strong > Wechsel ( s ) < / strong > = Pause bis zur < strong > nächsten Station < / strong > . Feld „ Pause zw .
Serien “ erscheint erst ab & nbsp ; 2 Serien ( sonst keine Pause zwischen zwei Blöcken nötig ) .
< / p >
) : null }
< / div >
)
} ) }
< div
onDragOver = { ( e ) => {
if ( ! e . dataTransfer ? . types ? . includes ? . ( DND _EXERCISE _COMBO _STATION ) ) return
e . preventDefault ( )
e . dataTransfer . dropEffect = 'move'
} }
onDrop = { ( e ) => {
const rawFrom = e . dataTransfer . getData ( DND _EXERCISE _COMBO _STATION )
const fromI = parseInt ( rawFrom , 10 )
e . preventDefault ( )
setComboDropTargetIx ( null )
if ( ! Number . isFinite ( fromI ) ) return
const len = ( formData . combination _slots || [ ] ) . length
reorderCombinationSlots ( fromI , len )
} }
style = { {
padding : '10px' ,
textAlign : 'center' ,
fontSize : '11px' ,
color : 'var(--text3)' ,
border : '1px dashed var(--border)' ,
borderRadius : '10px' ,
marginBottom : '8px' ,
} }
>
Hier ablegen zum Anhängen am Ende der Reihenfolge
< / div >
< button
type = "button"
className = "btn btn-secondary"
style = { { fontSize : '12px' , marginTop : '4px' } }
onClick = { ( ) => updateFormField ( 'combination_slots' , [ ... ( formData . combination _slots || [ ] ) , emptyComboSlotRow ( ) ] ) }
>
+ Station
< / button >
< / div >
< div className = "form-row" >
< label className = "form-label" > Ablaufprofil ( Runden & amp ; global ) < / label >
< CombinationMethodProfileEditor
methodArchetype = { formData . method _archetype || '' }
methodProfileJson = { formData . method _profile _json || '{}' }
onChangeMethodProfileJson = { ( s ) => updateFormField ( 'method_profile_json' , s ) }
comboSlotsOutline = { ( formData . combination _slots || [ ] ) . map ( ( r , i ) => ( {
slot _index : i ,
title : r . title || '' ,
} ) ) }
omitPerSlotTiming
/ >
< / div >
< / >
) : null }
< / div >
< div className = "form-row" >
< label className = "form-label" > Ziel * < / label >
< RichTextEditor
value = { formData . goal }
onChange = { ( html ) => updateFormField ( 'goal' , html ) }
placeholder = "Trainingsziel"
minHeight = "120px"
inlineExerciseId = { isEdit ? exerciseId : null }
linkedExerciseMedia = { isEdit ? mediaList : [ ] }
onExerciseMediaListChanged = { refreshMedia }
/ >
< / 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"
inlineExerciseId = { isEdit ? exerciseId : null }
linkedExerciseMedia = { isEdit ? mediaList : [ ] }
onExerciseMediaListChanged = { refreshMedia }
/ >
< / 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"
inlineExerciseId = { isEdit ? exerciseId : null }
linkedExerciseMedia = { isEdit ? mediaList : [ ] }
onExerciseMediaListChanged = { refreshMedia }
/ >
< / 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"
inlineExerciseId = { isEdit ? exerciseId : null }
linkedExerciseMedia = { isEdit ? mediaList : [ ] }
onExerciseMediaListChanged = { refreshMedia }
/ >
< / 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 >
< 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 >
2026-05-21 14:40:50 +02:00
< section className = "exercise-form-meta-panel" aria - labelledby = "exercise-meta-heading" >
< h3 id = "exercise-meta-heading" className = "exercise-form-meta-panel__title" >
Klassifikation & amp ; Zielgruppe
< / h3 >
< div className = "exercise-form-meta-panel__grid" >
< ExerciseCatalogAssocEditor
title = "Fokusbereiche"
rows = { formData . focus _areas _multi }
setRows = { ( r ) => updateFormField ( 'focus_areas_multi' , r ) }
options = { focusAreas }
idKey = "focus_area_id"
emptyLabel = "Optional — „+ Eintrag“."
/ >
2026-05-14 16:14:26 +02:00
2026-05-21 14:40:50 +02:00
< ExerciseCatalogAssocEditor
title = "Stilrichtungen"
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 = "Optional."
/ >
2026-05-14 16:14:26 +02:00
2026-05-21 14:40:50 +02:00
< ExerciseCatalogAssocEditor
title = "Trainingsstil"
rows = { formData . training _types _multi }
setRows = { ( r ) => updateFormField ( 'training_types_multi' , r ) }
options = { trainingTypes }
idKey = "training_type_id"
emptyLabel = "Optional."
/ >
2026-05-14 16:14:26 +02:00
2026-05-21 14:40:50 +02:00
< ExerciseCatalogAssocEditor
title = "Zielgruppen"
rows = { formData . target _groups _multi }
setRows = { ( r ) => updateFormField ( 'target_groups_multi' , r ) }
options = { targetGroups }
idKey = "target_group_id"
emptyLabel = "Optional."
showPrimary = { false }
2026-05-20 10:59:17 +02:00
/ >
2026-05-14 16:14:26 +02:00
< / div >
2026-05-21 14:40:50 +02:00
< ExerciseSkillsEditor
rows = { formData . skills }
skillsCatalog = { skillsCatalog }
skillPick = { skillPick }
onSkillPickChange = { setSkillPick }
onAdd = { addSkillRow }
onRemove = { removeSkillRow }
onUpdateField = { updateSkillField }
/ >
< / section >
2026-05-14 16:14:26 +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 >
{ isSuperadmin ? < option value = "official" > Offiziell < / option > : null }
< / 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 >
{ formData . visibility === 'club' && visibilityClubChoices . length > 0 ? (
< div className = "form-row" style = { { marginTop : '10px' } } >
< label className = "form-label" > Verein ( Sichtbarkeit ) < / label >
< select
className = "form-input"
value = { formData . club _id != null && formData . club _id !== '' ? String ( formData . club _id ) : '' }
onChange = { ( e ) => {
const v = e . target . value
updateFormField ( 'club_id' , v === '' ? null : Number ( v ) )
} }
>
{ visibilityClubChoices . map ( ( c ) => (
< option key = { c . id } value = { String ( c . id ) } >
{ ( c . name || '' ) . trim ( ) || ` Verein # ${ c . id } ` }
< / option >
) ) }
< / select >
< p style = { { margin : '6px 0 0' , fontSize : '12px' , color : 'var(--text3)' , lineHeight : 1.4 } } >
Standard ist der aktive Verein aus der Navigation . Bei Plattform - Admins sind alle Vereine wählbar .
< / p >
< / div >
) : null }
< / form >
< / div >
{ isEdit && formData . exercise _kind !== 'combination' && (
< 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 ) => {
setFormDirty ( true )
setVariantDraft ( ( d ) => ( { ... d , ... patch } ) )
} }
prerequisiteOthers = { variants }
rteMinHeight = "110px"
inlineExerciseId = { isEdit ? exerciseId : null }
linkedExerciseMedia = { isEdit ? mediaList : [ ] }
onExerciseMediaListChanged = { refreshMedia }
/ >
< 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"
inlineExerciseId = { isEdit ? exerciseId : null }
linkedExerciseMedia = { isEdit ? mediaList : [ ] }
onExerciseMediaListChanged = { refreshMedia }
/ >
< / 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 && formData . exercise _kind !== 'combination' && (
< 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' , 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 .
< / p >
< 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 >
< Link to = "/media" className = "btn btn-secondary" style = { { textDecoration : 'none' } } >
Medienbibliothek
< / Link >
< / div >
{ mediaList . length > 0 && (
< 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"
placeholder = "Titel (wird in der Vorschau und im Platzhalter genutzt)"
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 >
< / >
) }
< button
type = "button"
className = "btn btn-primary"
style = { { fontSize : '12px' , padding : '4px 10px' } }
disabled = { mediaSavingId === m . id }
onClick = { ( ) => saveMediaMeta ( m . id ) }
>
{ mediaSavingId === m . id ? '…' : 'Speichern' }
< / button >
< button
type = "button"
className = "btn btn-secondary"
style = { { fontSize : '12px' , padding : '4px 10px' } }
onClick = { ( ) => handleDeleteMedia ( m . id ) }
>
Entfernen
< / button >
< / div >
< / div >
< / li >
)
} ) }
< / ul >
) }
< 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 >
{ 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 && (
< MediaPreviewModal
title = { ( mediaPreview . title || '' ) . trim ( ) || mediaPreview . original _filename || ` Medium # ${ mediaPreview . id } ` }
media = { mediaPreview }
fileUrl = { mediaPreview . embed _url ? null : resolveExerciseMediaFileUrl ( exerciseId , mediaPreview ) }
onClose = { ( ) => setMediaPreview ( null ) }
onReport = {
! mediaPreview . asset _legal _hold _active
? ( ) => {
setReportTarget ( mediaPreview )
setMediaPreview ( null )
}
: null
}
/ >
) }
{ reportTarget && (
< ReportContentModal
targetType = "media_asset"
targetId = { reportTarget . media _asset _id || reportTarget . id }
targetLabel = { reportTarget . title || reportTarget . original _filename || ` Medium # ${ reportTarget . id } ` }
onClose = { ( ) => setReportTarget ( null ) }
/ >
) }
< / div >
) }
< ExercisePickerModal
open = { comboStationPickerIx !== null }
onClose = { ( ) => setComboStationPickerIx ( null ) }
exerciseKindAny = { [ 'simple' ] }
multiSelect
enableQuickCreateDraft
onSelectExercises = { ( picked ) => {
if ( comboStationPickerIx === null ) return
mergePickedExercisesIntoSlot ( comboStationPickerIx , picked )
setComboStationPickerIx ( null )
} }
/ >
< 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 >
< UnsavedChangesPrompt
blocker = { blocker }
isBusy = { saving }
onSave = { handleUnsavedDialogSave }
onDiscardWithoutSave = { ( ) => setFormDirty ( false ) }
2026-05-20 06:38:53 +02:00
detail = "Du hast ungespeicherte Änderungen vorgenommen. Möchtest du die Seite wirklich verlassen?"
2026-05-14 16:14:26 +02:00
/ >
2026-05-20 06:38:53 +02:00
< / PageFormEditorChrome >
2026-05-14 16:14:26 +02:00
)
}
export default ExerciseFormPageRoot