shinkan-jinkendo/frontend/src/components/exercises/ExerciseFormPageRoot.jsx
Lars 30dc30c7aa
Some checks failed
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Failing after 0s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Failing after 4m0s
Test Suite / playwright-tests (push) Failing after 3m41s
Enhance Tenant Context and Access Control Features
- Introduced `email_verified` and `account_state` attributes in the `TenantContext` to improve user state management.
- Updated the `resolve_tenant_context` function to dynamically fetch `email_verified` status from the database and determine `account_state` based on user roles and memberships.
- Implemented `assert_min_account_state` checks across various endpoints to enforce access control based on user account status.
- Incremented version to 1.1.0 in version.py to reflect these enhancements in tenant context management and access control.
2026-06-06 21:10:52 +02:00

3281 lines
131 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react'
import { useNavigate, useParams, Link, useLocation } from 'react-router-dom'
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'
import { normalizeSkillLevelSlug, formatSkillLevelSlug } from '../../constants/skillLevels'
import { stripHtmlToText } from '../../utils/htmlUtils'
import ExerciseCatalogAssocEditor from './ExerciseCatalogAssocEditor'
import ExerciseSkillsEditor from './ExerciseSkillsEditor'
import { useAuth } from '../../context/AuthContext'
import FeatureUsageBadge from '../FeatureUsageBadge'
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, FileText, BookOpen, Tags, Layers, GitBranch, Image as ImageIcon } from 'lucide-react'
import UnsavedChangesPrompt from '../UnsavedChangesPrompt'
import PageFormEditorChrome from '../PageFormEditorChrome'
import { ExerciseFormTabBar, ExerciseFormPanel } from './ExerciseFormLayout'
import { useNavReturn } from '../../hooks/useNavReturn'
import {
EXERCISES_LIST_PATH,
buildCurrentLocationReturnContext,
buildExercisesListReturnContext,
linkStateWithAppReturn,
preserveAppReturnOnNavigate,
} from '../../utils/navReturnContext'
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../../hooks/useUnsavedChangesBlocker'
import {
EXERCISE_SKILL_INTENSITY_DEFAULT,
normalizeExerciseSkillIntensity,
formatExerciseSkillIntensityLabel,
} from '../../constants/exerciseSkillIntensity'
import {
EXERCISE_VISIBILITY_CLUB_FIELD_LABEL,
EXERCISE_VISIBILITY_FIELD_LABEL,
} from '../../constants/exerciseGovernanceLabels'
const VARIANT_DIFFICULTY = [
{ 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 escapeHtmlText(s) {
return String(s)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
/** Plaintext fuer RichTextEditor: ein bis mehrere Absaetze, ohne bestehendes HTML zu zerstoeren. */
function aiPlainSummaryToMinimalHtml(text) {
const raw = String(text || '').trim()
if (!raw) return ''
const parts = raw.split(/\n+/).map((p) => p.trim()).filter(Boolean)
const paras = parts.length ? parts : [raw]
return paras.map((p) => `<p>${escapeHtmlText(p)}</p>`).join('')
}
const INSTRUCTION_AI_FIELD_DEFS = [
{ key: 'goal', label: 'Ziel' },
{ key: 'execution', label: 'Durchführung' },
{ key: 'preparation', label: 'Vorbereitung / Aufbau' },
{ key: 'trainer_notes', label: 'Hinweise für Trainer' },
]
function cloneExerciseSkillRows(rows) {
return Array.isArray(rows) ? rows.map((s) => ({ ...s })) : []
}
function buildNormalizedAiSkillRowFromApi(sug) {
const sid = Number(sug.skill_id)
if (!Number.isFinite(sid)) return null
return {
skill_id: sid,
intensity: normalizeExerciseSkillIntensity(sug.intensity),
required_level: normalizeSkillLevelSlug(sug.required_level) || 'grundlagen',
target_level:
normalizeSkillLevelSlug(sug.target_level) ||
normalizeSkillLevelSlug(sug.required_level) ||
'grundlagen',
is_primary: !!sug.is_primary,
ai_suggested: true,
}
}
function buildExerciseAiSuggestionPreview({
mode,
snapshotSummaryHtml,
snapshotSkills,
snapshotInstructions,
apiRes,
}) {
const summaryRequested = mode !== 'skills' && mode !== 'instructions'
const skillsRequested = mode !== 'summary' && mode !== 'instructions'
const instructionsRequested = mode === 'instructions'
let summaryAfterHtml = null
let summaryAfterPlain = ''
if (summaryRequested && apiRes.summary?.text) {
summaryAfterPlain = String(apiRes.summary.text).trim()
if (summaryAfterPlain) {
summaryAfterHtml = aiPlainSummaryToMinimalHtml(apiRes.summary.text)
}
}
const skillChoices = []
if (skillsRequested && Array.isArray(apiRes.skills)) {
for (const sug of apiRes.skills) {
const after = buildNormalizedAiSkillRowFromApi(sug)
if (!after) continue
const ix = snapshotSkills.findIndex((s) => Number(s.skill_id) === after.skill_id)
const before = ix >= 0 ? { ...snapshotSkills[ix] } : null
skillChoices.push({
key: String(after.skill_id),
skill_id: after.skill_id,
kind: before ? 'update' : 'add',
before,
after,
include: true,
})
}
}
const instructionChoices = []
if (instructionsRequested && apiRes.instructions?.fields) {
const fields = apiRes.instructions.fields
const snap = snapshotInstructions || {}
for (const def of INSTRUCTION_AI_FIELD_DEFS) {
const afterHtml = fields[def.key]
if (!afterHtml || !String(afterHtml).trim()) continue
const beforeHtml = snap[def.key] || ''
instructionChoices.push({
key: def.key,
field: def.key,
label: def.label,
beforePlain: stripHtmlToText(beforeHtml).trim(),
afterHtml: String(afterHtml),
afterPlain: stripHtmlToText(afterHtml).trim(),
include: true,
})
}
}
const hasSummaryProposal = !!(summaryRequested && summaryAfterHtml)
const hasSkillChoices = skillChoices.length > 0
const hasInstructionChoices = instructionChoices.length > 0
return {
mode,
applySummary: hasSummaryProposal,
summaryBeforePlain: stripHtmlToText(snapshotSummaryHtml || '').trim(),
summaryAfterPlain,
summaryAfterHtml,
skillChoices,
instructionChoices,
hasSummaryProposal,
hasSkillChoices,
hasInstructionChoices,
summaryRequested,
skillsRequested,
instructionsRequested,
}
}
function describeExerciseSkillRowForPreview(row, skillsCatalog) {
if (!row) return ''
const sk = skillsCatalog.find((x) => Number(x.id) === Number(row.skill_id))
const name = sk?.name || `Fähigkeit #${row.skill_id}`
const int = formatExerciseSkillIntensityLabel(row.intensity)
const from = formatSkillLevelSlug(row.required_level) || '—'
const to = formatSkillLevelSlug(row.target_level) || '—'
const prim = row.is_primary ? ' · Primär' : ''
return `${name}: Intensität ${int}, Niveau ${from}${to}${prim}`
}
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),
}
}
function snapshotVariantPayload(row) {
return JSON.stringify(buildVariantPayloadFromRow(row))
}
function variantDraftHasContent(draft) {
if (!draft) return false
const p = buildVariantPayloadFromRow(draft)
return (
p.variant_name.length > 0 ||
Boolean(p.description) ||
Boolean(p.execution_changes) ||
p.duration_min != null ||
p.duration_max != null ||
(Array.isArray(p.equipment_changes) && p.equipment_changes.length > 0) ||
Boolean(p.difficulty_adjustment) ||
(p.progression_level != null && p.progression_level !== 1) ||
p.prerequisite_variant_id != null
)
}
/** 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 (110)</label>
<input
type="number"
min={1}
max={10}
className="form-input"
value={row.progression_level}
onChange={(e) =>
onPatch({
progression_level: e.target.value === '' ? '' : parseInt(e.target.value, 10),
})
}
/>
</div>
<div className="form-row">
<label className="form-label">Voraussetzungs-Variante</label>
<select
className="form-input"
value={
row.prerequisite_variant_id === '' || row.prerequisite_variant_id == null
? ''
: String(row.prerequisite_variant_id)
}
onChange={(e) =>
onPatch({
prerequisite_variant_id: e.target.value === '' ? '' : parseInt(e.target.value, 10),
})
}
>
<option value=""> keine </option>
{prerequisiteOthers.map((o) => (
<option key={o.id} value={o.id}>
{o.variant_name || `Variante #${o.id}`}
</option>
))}
</select>
</div>
</div>
</>
)
}
function emptyForm() {
return {
title: '',
summary: '',
goal: '',
execution: '',
preparation: '',
trainer_notes: '',
equipmentLines: '',
duration_min: '',
duration_max: '',
group_size_min: '',
group_size_max: '',
focus_areas_multi: [],
training_styles_multi: [],
training_types_multi: [],
target_groups_multi: [],
visibility: 'private',
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,
intensity: normalizeExerciseSkillIntensity(s.intensity),
required_level: normalizeSkillLevelSlug(s.required_level),
target_level: normalizeSkillLevelSlug(s.target_level),
is_primary: !!s.is_primary,
ai_suggested: !!s.ai_suggested,
})) || [],
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()
const location = useLocation()
const exercisesListReturn = useMemo(() => buildExercisesListReturnContext(), [])
const { goBack } = useNavReturn(exercisesListReturn)
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 [aiSuggestBusy, setAiSuggestBusy] = useState(false)
const [aiSuggestionPreview, setAiSuggestionPreview] = useState(null)
const [variantEditSelection, setVariantEditSelection] = useState(null)
const [activeFormTab, setActiveFormTab] = useState('stammdaten')
const variantsSavedSnapshotRef = useRef({})
const exerciseFormTabs = useMemo(() => {
const tabs = [
{ id: 'stammdaten', label: 'Stammdaten', icon: FileText },
{ id: 'anleitung', label: 'Anleitung', icon: BookOpen },
{ id: 'einordnung', label: 'Einordnung', icon: Tags },
]
if (formData.exercise_kind === 'combination') {
tabs.push({ id: 'kombination', label: 'Kombination', icon: Layers })
}
if (isEdit) {
if (formData.exercise_kind !== 'combination') {
tabs.push({
id: 'varianten',
label: variants.length > 0 ? `Varianten (${variants.length})` : 'Varianten',
icon: GitBranch,
})
}
tabs.push({ id: 'medien', label: 'Medien & Mehr', icon: ImageIcon })
} else {
tabs.push({ id: 'varianten', label: 'Varianten', icon: GitBranch, disabled: true })
tabs.push({ id: 'medien', label: 'Medien & Mehr', icon: ImageIcon, disabled: true })
}
return tabs
}, [formData.exercise_kind, isEdit, variants.length])
useEffect(() => {
const allowed = new Set(exerciseFormTabs.filter((t) => !t.disabled).map((t) => t.id))
if (!allowed.has(activeFormTab)) setActiveFormTab('stammdaten')
}, [exerciseFormTabs, activeFormTab])
useEffect(() => {
if (formData.exercise_kind === 'combination' && activeFormTab === 'varianten') {
setActiveFormTab('kombination')
}
}, [formData.exercise_kind, activeFormTab])
const syncVariantsSavedSnapshot = useCallback((rows) => {
const snap = {}
for (const v of rows || []) {
if (v?.id != null) snap[v.id] = snapshotVariantPayload(v)
}
variantsSavedSnapshotRef.current = snap
}, [])
const getDirtyVariantRows = useCallback((rows) => {
return (rows || []).filter((v) => {
if (v?.id == null) return false
const saved = variantsSavedSnapshotRef.current[v.id]
if (saved == null) return true
return snapshotVariantPayload(v) !== saved
})
}, [])
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([
api.listSkillsCatalog(),
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
const variantRows = (exercise.variants || []).map(apiVariantToRow)
setFormData(detailToForm(exercise))
setMediaList(exercise.media || [])
setVariants(variantRows)
syncVariantsSavedSnapshot(variantRows)
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 && isEdit && formData.exercise_kind !== 'combination') {
setActiveFormTab('varianten')
}
}, [variantEditSelection, isEdit, formData.exercise_kind])
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 WechselPool. Ü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,
intensity: EXERCISE_SKILL_INTENSITY_DEFAULT,
required_level: '',
target_level: '',
},
])
setSkillPick('')
}
const removeSkillRow = (idx) => {
updateFormField(
'skills',
formData.skills.filter((_, i) => i !== idx),
)
}
const updateSkillField = (idx, field, value) => {
updateFormField(
'skills',
formData.skills.map((s, i) => (i === idx ? { ...s, [field]: value } : s)),
)
}
const runExerciseAiSuggestion = async (mode) => {
const gPlain = stripHtmlToText(formData.goal || '').trim()
const ePlain = stripHtmlToText(formData.execution || '').trim()
if (!gPlain && !ePlain) {
toast.error('Ziel oder Durchführung ausfüllen — die KI benötigt Kontext.')
return
}
const summaryOn = mode !== 'skills'
const skillsOn = mode !== 'summary'
const focusHint = (formData.focus_areas_multi || [])
.map((row) => {
const id = row?.focus_area_id
const fa = focusAreas.find((x) => Number(x.id) === Number(id))
return (fa?.name || '').trim()
})
.filter(Boolean)
.join(', ')
const snapshotSummaryHtml = formData.summary || ''
const snapshotSkills = cloneExerciseSkillRows(formData.skills)
const focusAreasContext = [...(formData.focus_areas_multi || [])]
.map((row) => ({
focus_area_id: Number(row?.focus_area_id),
is_primary: !!row?.is_primary,
}))
.filter((x) => Number.isFinite(x.focus_area_id) && x.focus_area_id >= 1)
.sort((a, b) => {
const p = Number(!!b.is_primary) - Number(!!a.is_primary)
if (p !== 0) return p
return a.focus_area_id - b.focus_area_id
})
/* Vor jedem neuen Aufruf: Vorschau schließen; sonst bleiben die KI-Buttons wegen Modal-Zustand dauerhaft deaktiviert. */
setAiSuggestionPreview(null)
setAiSuggestBusy(true)
try {
const res = await api.suggestExerciseAi({
title: (formData.title || '').trim(),
goal: formData.goal || '',
execution: formData.execution || '',
focus_area_hint: focusHint || undefined,
focus_areas_context: focusAreasContext.length ? focusAreasContext : undefined,
include_summary: summaryOn,
include_skills: skillsOn,
})
const preview = buildExerciseAiSuggestionPreview({
mode,
snapshotSummaryHtml,
snapshotSkills,
apiRes: res,
})
const hasSomething = preview.hasSummaryProposal || preview.hasSkillChoices
if (!hasSomething) {
toast.info('Die KI lieferte keinen verwertbaren Vorschlag für die gewählten Bereiche.')
return
}
setAiSuggestionPreview(preview)
} catch (err) {
toast.error(err?.message || String(err))
} finally {
setAiSuggestBusy(false)
}
}
const runExerciseAiInstructionRewrite = async () => {
const title = (formData.title || '').trim()
const snapshotInstructions = {
goal: formData.goal || '',
execution: formData.execution || '',
preparation: formData.preparation || '',
trainer_notes: formData.trainer_notes || '',
}
const hasSource =
!!title ||
Object.values(snapshotInstructions).some((html) => stripHtmlToText(html || '').trim())
if (!hasSource) {
toast.error('Titel oder mindestens ein Anleitungsfeld ausfüllen.')
return
}
const focusHint = (formData.focus_areas_multi || [])
.map((row) => {
const id = row?.focus_area_id
const fa = focusAreas.find((x) => Number(x.id) === Number(id))
return (fa?.name || '').trim()
})
.filter(Boolean)
.join(', ')
const focusAreasContext = [...(formData.focus_areas_multi || [])]
.map((row) => ({
focus_area_id: Number(row?.focus_area_id),
is_primary: !!row?.is_primary,
}))
.filter((x) => Number.isFinite(x.focus_area_id) && x.focus_area_id >= 1)
.sort((a, b) => {
const p = Number(!!b.is_primary) - Number(!!a.is_primary)
if (p !== 0) return p
return a.focus_area_id - b.focus_area_id
})
setAiSuggestionPreview(null)
setAiSuggestBusy(true)
try {
const res = await api.suggestExerciseAi({
title,
goal: snapshotInstructions.goal,
execution: snapshotInstructions.execution,
preparation: snapshotInstructions.preparation,
trainer_notes: snapshotInstructions.trainer_notes,
focus_area_hint: focusHint || undefined,
focus_areas_context: focusAreasContext.length ? focusAreasContext : undefined,
include_summary: false,
include_skills: false,
include_instructions: true,
})
const preview = buildExerciseAiSuggestionPreview({
mode: 'instructions',
snapshotInstructions,
apiRes: res,
})
if (!preview.hasInstructionChoices) {
toast.info('Die KI lieferte keinen verwertbaren Anleitungs-Vorschlag.')
return
}
setAiSuggestionPreview(preview)
} catch (err) {
toast.error(err?.message || String(err))
} finally {
setAiSuggestBusy(false)
}
}
const applyExerciseAiSuggestionPreview = () => {
const p = aiSuggestionPreview
if (!p) return
const takeSummary = !!(p.applySummary && p.summaryAfterHtml)
const skillsToMerge = p.skillChoices.filter((c) => c.include).map((c) => c.after)
const instrToApply = (p.instructionChoices || []).filter((c) => c.include && c.afterHtml)
if (!takeSummary && skillsToMerge.length === 0 && instrToApply.length === 0) {
toast.error('Bitte mindestens einen Vorschlag zur Übernahme auswählen.')
return
}
if (takeSummary) {
updateFormField('summary', p.summaryAfterHtml)
}
for (const c of instrToApply) {
updateFormField(c.field, c.afterHtml)
}
if (skillsToMerge.length > 0) {
setFormDirty(true)
setFormData((prev) => {
const next = [...(prev.skills || [])]
for (const row of skillsToMerge) {
const sid = Number(row.skill_id)
const ix = next.findIndex((s) => Number(s.skill_id) === sid)
if (ix >= 0) next[ix] = { ...next[ix], ...row }
else next.push(row)
}
return { ...prev, skills: next }
})
}
toast.success('Ausgewählte KI-Vorschläge übernommen — bitte prüfen und speichern.')
setAiSuggestionPreview(null)
}
const discardExerciseAiSuggestionPreview = () => setAiSuggestionPreview(null)
useEffect(() => {
if (!aiSuggestionPreview) return undefined
const onKey = (e) => {
if (e.key === 'Escape') {
e.preventDefault()
setAiSuggestionPreview(null)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [aiSuggestionPreview])
const refreshVariants = useCallback(async () => {
if (!exerciseId) return
const ex = await api.getExercise(exerciseId)
const rows = (ex.variants || []).map(apiVariantToRow)
syncVariantsSavedSnapshot(rows)
setVariants(rows)
}, [exerciseId, syncVariantsSavedSnapshot])
const createVariantFromDraft = useCallback(
async ({ showSuccessToast = false } = {}) => {
if (!exerciseId) return false
if (!variantDraftHasContent(variantDraft)) return true
const payload = buildVariantPayloadFromRow(variantDraft)
if (payload.variant_name.length < 3) {
toast.error('Variantenname mindestens 3 Zeichen')
return false
}
setVariantBusy(true)
try {
const created = await api.createExerciseVariant(exerciseId, payload)
setVariantDraft(emptyVariantDraft())
if (created?.id != null) setVariantEditSelection(created.id)
await refreshVariants()
if (showSuccessToast) toast.success('Variante angelegt.')
return true
} catch (e) {
toast.error(e.message || String(e))
return false
} finally {
setVariantBusy(false)
}
},
[exerciseId, variantDraft, refreshVariants, toast],
)
const persistPendingVariantChanges = useCallback(async () => {
if (!exerciseId) return true
const dirtyRows = getDirtyVariantRows(variants)
if (dirtyRows.length > 0) {
setVariantBusy(true)
try {
for (const row of dirtyRows) {
const payload = buildVariantPayloadFromRow(row)
if (payload.variant_name.length < 3) {
toast.error(`Variante „${row.variant_name || `#${row.id}`}“: Name mindestens 3 Zeichen`)
return false
}
setVariantSavingId(row.id)
await api.updateExerciseVariant(exerciseId, row.id, payload)
}
await refreshVariants()
} catch (e) {
toast.error(e.message || String(e))
return false
} finally {
setVariantSavingId(null)
setVariantBusy(false)
}
}
const draftOk = await createVariantFromDraft()
return draftOk
}, [exerciseId, variants, getDirtyVariantRows, refreshVariants, toast, createVariantFromDraft])
const performSaveAttempt = useCallback(
async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => {
if (!formData.title || formData.title.trim().length < 3) {
toast.error('Titel mindestens 3 Zeichen')
return false
}
if (isEdit && exerciseId) {
const variantsOk = await persistPendingVariantChanges()
if (!variantsOk) 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 || [])
const variantRows = (ex.variants || []).map(apiVariantToRow)
setVariants(variantRows)
syncVariantsSavedSnapshot(variantRows)
setFormDirty(false)
toast.success('Gespeichert.')
if (closeAfter) goBack()
return true
}
const created = await api.createExercise(payload)
setFormDirty(false)
toast.success('Übung angelegt.')
if (closeAfter) {
goBack()
} else if (!fromUnsavedDialog) {
preserveAppReturnOnNavigate(navigate, location, `/exercises/${created.id}/edit`, { replace: true })
}
return true
} catch (err) {
toast.error('Fehler beim Speichern: ' + err.message)
return false
} finally {
setSaving(false)
}
},
[exerciseId, formData, isEdit, navigate, location, toast, goBack, persistPendingVariantChanges, syncVariantsSavedSnapshot],
)
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,
onCancel: goBack,
showSave: true,
showSaveAndClose: true,
}),
[saving, isEdit, handleSubmit, handleSaveAndClose, goBack],
)
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 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 handleCreateVariantClick = useCallback(async () => {
await createVariantFromDraft({ showSuccessToast: true })
}, [createVariantFromDraft])
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 (
<PageFormEditorChrome
testId="exercise-form-page"
title={isEdit ? 'Übung bearbeiten' : 'Neue Übung'}
fallbackPath={EXERCISES_LIST_PATH}
fallbackLabel="Zurück zur Übungsliste"
actionConfig={actionConfig}
>
{isEdit ? (
<p style={{ margin: '0 0 12px' }}>
<Link
to={`/exercises/${exerciseId}`}
state={linkStateWithAppReturn(
buildCurrentLocationReturnContext(location, 'Zurück zur Bearbeitung')
)}
className="btn btn-secondary"
style={{ fontSize: '0.875rem' }}
>
Ansehen
</Link>
</p>
) : null}
<div className="card exercise-form-edit">
<form id="exercise-form" onSubmit={handleSubmit}>
<ExerciseFormTabBar
activeTab={activeFormTab}
onChange={setActiveFormTab}
items={exerciseFormTabs}
/>
<ExerciseFormPanel
tab="stammdaten"
activeTab={activeFormTab}
tone="basics"
title="Stammdaten"
hint={
isEdit
? 'Titel, Rahmendaten und Freigabelevel — Inhalt und Einordnung in den anderen Tabs.'
: 'Titel und Rahmendaten. Varianten, Medien und Progressionsgraph sind nach dem ersten Speichern verfügbar.'
}
>
<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">
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
flexWrap: 'wrap',
}}
>
<label className="form-label" style={{ marginBottom: 0 }}>
Kurzbeschreibung
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
<FeatureUsageBadge featureId="ai_calls" />
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px' }}
disabled={aiSuggestBusy}
onClick={() => runExerciseAiSuggestion('summary')}
>
KI: Kurzfassung
</button>
</div>
</div>
<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 className="exercise-form-type-box">
<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()],
}
: {}),
}))
if (nk === 'combination') setActiveFormTab('kombination')
}}
>
<option value="simple">Einzelübung</option>
<option value="combination">Kombinationsübung (Stationen / Pool)</option>
</select>
</div>
{formData.exercise_kind === 'combination' ? (
<p className="exercise-form-type-box__hint">
Stationen und Ablaufprofil im Tab{' '}
<button type="button" className="exercise-form-inline-tab-link" onClick={() => setActiveFormTab('kombination')}>
Kombination
</button>
.
</p>
) : null}
</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>
<div className="form-row">
<label className="form-label">Material (eine Zeile oder kommagetrennt)</label>
<textarea
className="form-input"
rows={3}
value={formData.equipmentLines}
onChange={(e) => updateFormField('equipmentLines', e.target.value)}
placeholder="Matten&#10;Pratzen"
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div className="form-row">
<label className="form-label">{EXERCISE_VISIBILITY_FIELD_LABEL}</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">{EXERCISE_VISIBILITY_CLUB_FIELD_LABEL}</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}
</ExerciseFormPanel>
<ExerciseFormPanel
tab="kombination"
activeTab={activeFormTab}
tone="combo"
title="Kombinationsübung"
hint="Stationen, Übungs-Pools und globales Ablaufprofil für Coach und Planung."
>
{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 AuswahlPool.
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.&nbsp;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>
</>
) : (
<p className="exercise-form-panel__hint" style={{ margin: 0 }}>
Wähle unter <strong>Stammdaten</strong> die Art Kombinationsübung, um Stationen zu planen.
</p>
)}
</ExerciseFormPanel>
<ExerciseFormPanel
tab="anleitung"
activeTab={activeFormTab}
tone="guide"
title="Anleitung"
hint="Ziel, Ablauf und Hinweise — Medien kannst du in die Texte einbetten (Symbolleiste)."
>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
marginBottom: '12px',
alignItems: 'center',
}}
>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px' }}
disabled={aiSuggestBusy}
onClick={() => runExerciseAiInstructionRewrite()}
>
KI: Anleitung überarbeiten
</button>
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
Überarbeitet Ziel, Durchführung, Vorbereitung und Trainer-Hinweise prägnant und strukturiert. Vorschau
im Dialog; nichts wird automatisch gespeichert.
</span>
</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>
</ExerciseFormPanel>
<ExerciseFormPanel
tab="einordnung"
activeTab={activeFormTab}
tone="classify"
title="Einordnung"
hint="Fokus, Stile, Zielgruppen und Fähigkeiten für Suche, Filter und Skill-Profil."
>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
marginBottom: '12px',
alignItems: 'center',
}}
>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px' }}
disabled={aiSuggestBusy}
onClick={() => runExerciseAiSuggestion('skills')}
>
KI: Fähigkeiten
</button>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '12px' }}
disabled={aiSuggestBusy}
onClick={() => runExerciseAiSuggestion('both')}
>
KI: Kurzfassung und Fähigkeiten
</button>
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
Benötigt Ziel oder Durchführung sowie optional{' '}
<button type="button" className="exercise-form-inline-tab-link" onClick={() => setActiveFormTab('anleitung')}>
Anleitung
</button>
· Es öffnet ein Dialogfeld mit Vorschau; Übernahme wählweise pro Teil. Speichern nur über die Aktionsleiste.
</span>
</div>
<section className="exercise-form-meta-panel" aria-label="Klassifikation">
<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“."
/>
<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."
/>
<ExerciseCatalogAssocEditor
title="Trainingsstil"
rows={formData.training_types_multi}
setRows={(r) => updateFormField('training_types_multi', r)}
options={trainingTypes}
idKey="training_type_id"
emptyLabel="Optional."
/>
<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}
/>
</div>
<ExerciseSkillsEditor
rows={formData.skills}
skillsCatalog={skillsCatalog}
skillPick={skillPick}
onSkillPickChange={setSkillPick}
onAdd={addSkillRow}
onRemove={removeSkillRow}
onUpdateField={updateSkillField}
/>
</section>
</ExerciseFormPanel>
{isEdit && formData.exercise_kind !== 'combination' ? (
<ExerciseFormPanel
tab="varianten"
activeTab={activeFormTab}
tone="variants"
title="Übungsvarianten"
hint="Pro Durchgang eine Variante. Änderungen werden mit Speichern in der Aktionsleiste mitgesichert."
>
{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' && (
<div
className="exercise-variant-single-form"
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="button"
className="btn btn-primary"
style={{ marginTop: '10px' }}
disabled={variantBusy}
onClick={handleCreateVariantClick}
>
{variantBusy ? 'Anlegen…' : 'Variante anlegen'}
</button>
<p className="exercise-form-panel__hint" style={{ marginTop: '8px', marginBottom: 0 }}>
Alternativ reicht Speichern in der Aktionsleiste der Entwurf wird dann mitgesichert.
</p>
</div>
)}
{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-secondary"
style={{ marginLeft: 'auto', fontSize: '12px' }}
disabled={variantSavingId === selectedVariantForEdit.id || variantBusy}
onClick={() => saveVariantRow(selectedVariantForEdit)}
title="Optional — Änderungen werden auch über die Aktionsleiste gespeichert"
>
{variantSavingId === selectedVariantForEdit.id ? 'Speichern…' : 'Variante jetzt 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>
)}
</ExerciseFormPanel>
) : null}
{isEdit ? (
<ExerciseFormPanel
tab="medien"
activeTab={activeFormTab}
tone="media"
title="Medien & Erweiterungen"
hint="Verknüpfte Dateien, Progressionsgraph und Medienarchiv."
>
<div className="exercise-form-subsection exercise-form-subsection--media">
<h4 className="exercise-form-subsection__title">Medien</h4>
<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>
</div>
{formData.exercise_kind !== 'combination' ? (
<div className="exercise-form-subsection exercise-form-subsection--graph">
<h4 className="exercise-form-subsection__title">Progressionsgraph</h4>
<p className="exercise-form-subsection__hint">Übergänge zu anderen Übungen für Progressions-Serien.</p>
<ExerciseProgressionGraphPanel anchorExerciseId={exerciseId} anchorTitle={formData.title} />
</div>
) : null}
{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)}
/>
)}
</ExerciseFormPanel>
) : null}
</form>
</div>
{aiSuggestionPreview &&
(() => {
const p = aiSuggestionPreview
const summaryBoxSx = {
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
fontSize: '13px',
lineHeight: 1.45,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
minHeight: '72px',
}
const canApplySomething =
(p.applySummary && p.summaryAfterHtml) ||
p.skillChoices.some((c) => c.include) ||
(p.instructionChoices || []).some((c) => c.include && c.afterHtml)
const dialogTitle =
p.instructionsRequested
? 'KI: Anleitung überarbeiten'
: 'KI-Vorschlag übernehmen'
return (
<div
role="dialog"
aria-modal="true"
aria-label="KI-Vorschlag prüfen"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
zIndex: 1001,
overflow: 'auto',
padding: '16px',
}}
onClick={() => discardExerciseAiSuggestionPreview()}
onKeyDown={(e) => e.key === 'Escape' && discardExerciseAiSuggestionPreview()}
>
<div
className="card"
style={{
maxWidth: 760,
margin: '3vh auto',
maxHeight: '92vh',
overflow: 'auto',
position: 'relative',
}}
onClick={(e) => e.stopPropagation()}
>
<h3 style={{ marginTop: 0, fontSize: '1.1rem', marginBottom: '6px' }}>{dialogTitle}</h3>
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0, marginBottom: '16px' }}>
{p.instructionsRequested
? 'Vergleichen und nur die gewünschten Felder übernehmen. Eingebettete Medien bleiben erhalten, wenn die KI sie nicht erwähnt.'
: 'Vergleichen und nur die gewünschten Teile übernehmen. Es werden keine Daten automatisch gespeichert.'}
</p>
{p.hasInstructionChoices ? (
<section style={{ marginBottom: '20px' }} aria-labelledby="ai-preview-instructions-heading">
<div
id="ai-preview-instructions-heading"
style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '10px' }}
>
Anleitung ({p.instructionChoices.length}{' '}
{p.instructionChoices.length === 1 ? 'Feld' : 'Felder'})
</div>
{p.instructionChoices.map((c) => (
<div
key={c.key}
style={{
border: '1px solid var(--border)',
borderRadius: '8px',
padding: '12px',
marginBottom: '12px',
background: 'var(--surface)',
}}
>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '10px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 600,
}}
>
<input
type="checkbox"
checked={c.include}
onChange={(e) =>
setAiSuggestionPreview((prev) =>
prev
? {
...prev,
instructionChoices: prev.instructionChoices.map((x) =>
x.key === c.key ? { ...x, include: e.target.checked } : x,
),
}
: prev,
)
}
/>
{c.label} übernehmen
</label>
<div
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)',
gap: '12px',
}}
>
<div>
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>
Aktuell (Plaintext)
</div>
<div style={summaryBoxSx}>{c.beforePlain || '(leer)'}</div>
</div>
<div>
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>
KI-Vorschlag
</div>
<div
style={{
...summaryBoxSx,
borderColor: 'var(--accent-dark, rgba(29,158,117,0.45))',
}}
>
{c.afterPlain || '(leer)'}
</div>
</div>
</div>
</div>
))}
</section>
) : null}
{p.hasSummaryProposal ? (
<section style={{ marginBottom: '20px' }} aria-labelledby="ai-preview-summary-heading">
<div
id="ai-preview-summary-heading"
style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '10px' }}
>
Kurzfassung
</div>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '10px',
cursor: 'pointer',
fontSize: '14px',
}}
>
<input
type="checkbox"
checked={p.applySummary}
onChange={(e) =>
setAiSuggestionPreview((prev) =>
prev ? { ...prev, applySummary: e.target.checked } : prev,
)
}
/>
Kurzfassung durch Vorschlag ersetzen (bestehende Kurzbeschreibung wird überschrieben)
</label>
<div
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)',
gap: '12px',
}}
>
<div>
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>
Aktuell (ohne Formatierung)
</div>
<div style={summaryBoxSx}>{p.summaryBeforePlain || '(leer)'}</div>
</div>
<div>
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>KI-Vorschlag</div>
<div style={{ ...summaryBoxSx, borderColor: 'var(--accent-dark, rgba(29,158,117,0.45))' }}>
{p.summaryAfterPlain || '(leer)'}
</div>
</div>
</div>
</section>
) : null}
{p.skillsRequested ? (
<section aria-labelledby="ai-preview-skills-heading">
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
marginBottom: '10px',
}}
>
<div id="ai-preview-skills-heading" style={{ fontWeight: 600, fontSize: '0.95rem' }}>
Fähigkeiten ({p.skillChoices.length}
{p.skillChoices.length === 1 ? ' Vorschlag' : ' Vorschläge'})
</div>
{p.skillChoices.length > 0 ? (
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }}
onClick={() =>
setAiSuggestionPreview((prev) =>
prev
? {
...prev,
skillChoices: prev.skillChoices.map((x) => ({ ...x, include: true })),
}
: prev,
)
}
>
Alle auswählen
</button>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }}
onClick={() =>
setAiSuggestionPreview((prev) =>
prev
? {
...prev,
skillChoices: prev.skillChoices.map((x) => ({ ...x, include: false })),
}
: prev,
)
}
>
Alle abwählen
</button>
</div>
) : null}
</div>
{p.skillChoices.length === 0 ? (
<p style={{ fontSize: '13px', color: 'var(--text3)', margin: 0 }}>
Keine passenden Fähigkeiten der Katalog-Vorschlag war leer oder enthielt nur ungültige IDs.
</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{p.skillChoices.map((c) => (
<li
key={c.key}
style={{
border: '1px solid var(--border)',
borderRadius: '8px',
padding: '10px 12px',
marginBottom: '10px',
background: 'var(--surface)',
}}
>
<label
style={{
display: 'flex',
gap: '10px',
alignItems: 'flex-start',
cursor: 'pointer',
margin: 0,
}}
>
<input
type="checkbox"
checked={c.include}
onChange={() =>
setAiSuggestionPreview((prev) =>
prev
? {
...prev,
skillChoices: prev.skillChoices.map((x) =>
x.skill_id === c.skill_id ? { ...x, include: !x.include } : x,
),
}
: prev,
)
}
style={{ marginTop: '4px' }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: '13px', marginBottom: '6px' }}>
{c.kind === 'add' ? 'Neu hinzufügen' : 'Bestehende Zeile aktualisieren'}
</div>
{c.kind === 'update' && c.before ? (
<div style={{ fontSize: '12px', lineHeight: 1.5 }}>
<div style={{ color: 'var(--text3)', marginBottom: '2px' }}>Bisher</div>
<div style={{ marginBottom: '8px' }}>
{describeExerciseSkillRowForPreview(c.before, skillsCatalog)}
</div>
<div style={{ color: 'var(--text3)', marginBottom: '2px' }}>Nach KI-Vorschlag</div>
<div>{describeExerciseSkillRowForPreview(c.after, skillsCatalog)}</div>
</div>
) : (
<div style={{ fontSize: '13px', lineHeight: 1.5 }}>
{describeExerciseSkillRowForPreview(c.after, skillsCatalog)}
</div>
)}
</div>
</label>
</li>
))}
</ul>
)}
</section>
) : null}
<div
style={{
marginTop: '20px',
paddingTop: '14px',
borderTop: '1px solid var(--border)',
display: 'flex',
justifyContent: 'flex-end',
flexWrap: 'wrap',
gap: '10px',
}}
>
<button type="button" className="btn btn-secondary" onClick={discardExerciseAiSuggestionPreview}>
Abbrechen
</button>
<button
type="button"
className="btn btn-primary"
disabled={!canApplySomething}
onClick={() => applyExerciseAiSuggestionPreview()}
>
Ausgewähltes übernehmen
</button>
</div>
</div>
</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-Unterstützung:</strong> OpenRouter-Vorschläge für Kurzfassung, Fähigkeiten und Anleitung
(<code>suggestExerciseAi</code> / <code>regenerateExerciseAi</code>). Übernahme im Dialog ins Formular; Speichern
wie gewohnt.
</p>
<UnsavedChangesPrompt
blocker={blocker}
isBusy={saving}
onSave={handleUnsavedDialogSave}
onDiscardWithoutSave={() => setFormDirty(false)}
detail="Du hast ungespeicherte Änderungen vorgenommen. Möchtest du die Seite wirklich verlassen?"
/>
</PageFormEditorChrome>
)
}
export default ExerciseFormPageRoot