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
- 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.
3281 lines
131 KiB
JavaScript
3281 lines
131 KiB
JavaScript
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, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
}
|
||
|
||
/** 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 (1–10)</label>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={10}
|
||
className="form-input"
|
||
value={row.progression_level}
|
||
onChange={(e) =>
|
||
onPatch({
|
||
progression_level: e.target.value === '' ? '' : parseInt(e.target.value, 10),
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="form-row">
|
||
<label className="form-label">Voraussetzungs-Variante</label>
|
||
<select
|
||
className="form-input"
|
||
value={
|
||
row.prerequisite_variant_id === '' || row.prerequisite_variant_id == null
|
||
? ''
|
||
: String(row.prerequisite_variant_id)
|
||
}
|
||
onChange={(e) =>
|
||
onPatch({
|
||
prerequisite_variant_id: e.target.value === '' ? '' : parseInt(e.target.value, 10),
|
||
})
|
||
}
|
||
>
|
||
<option value="">— keine —</option>
|
||
{prerequisiteOthers.map((o) => (
|
||
<option key={o.id} value={o.id}>
|
||
{o.variant_name || `Variante #${o.id}`}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
function emptyForm() {
|
||
return {
|
||
title: '',
|
||
summary: '',
|
||
goal: '',
|
||
execution: '',
|
||
preparation: '',
|
||
trainer_notes: '',
|
||
equipmentLines: '',
|
||
duration_min: '',
|
||
duration_max: '',
|
||
group_size_min: '',
|
||
group_size_max: '',
|
||
focus_areas_multi: [],
|
||
training_styles_multi: [],
|
||
training_types_multi: [],
|
||
target_groups_multi: [],
|
||
visibility: 'private',
|
||
club_id: null,
|
||
status: 'draft',
|
||
skills: [],
|
||
exercise_kind: 'simple',
|
||
method_archetype: '',
|
||
method_profile_json: '{}',
|
||
combination_slots: [emptyComboSlotRow()],
|
||
}
|
||
}
|
||
|
||
function detailToForm(exercise) {
|
||
return {
|
||
title: exercise.title || '',
|
||
summary: exercise.summary || '',
|
||
goal: exercise.goal || '',
|
||
execution: exercise.execution || '',
|
||
preparation: exercise.preparation || '',
|
||
trainer_notes: exercise.trainer_notes || '',
|
||
equipmentLines: (exercise.equipment || []).join('\n'),
|
||
duration_min: exercise.duration_min ?? '',
|
||
duration_max: exercise.duration_max ?? '',
|
||
group_size_min: exercise.group_size_min ?? '',
|
||
group_size_max: exercise.group_size_max ?? '',
|
||
focus_areas_multi: (exercise.focus_areas || []).map((f) => ({
|
||
focus_area_id: f.focus_area_id,
|
||
is_primary: !!f.is_primary,
|
||
})),
|
||
training_styles_multi: (exercise.training_styles || []).map((t) => ({
|
||
training_style_id: t.training_style_id,
|
||
is_primary: !!t.is_primary,
|
||
})),
|
||
training_types_multi: (exercise.training_types || []).map((t) => ({
|
||
training_type_id: t.training_type_id,
|
||
is_primary: !!t.is_primary,
|
||
})),
|
||
target_groups_multi: (exercise.target_groups || []).map((g) => ({
|
||
target_group_id: g.target_group_id,
|
||
is_primary: !!g.is_primary,
|
||
})),
|
||
visibility: exercise.visibility || 'private',
|
||
club_id:
|
||
String(exercise.visibility || '').trim().toLowerCase() === 'club' &&
|
||
exercise.club_id != null &&
|
||
exercise.club_id !== ''
|
||
? Number(exercise.club_id)
|
||
: null,
|
||
status: exercise.status || 'draft',
|
||
skills:
|
||
exercise.skills?.map((s) => ({
|
||
skill_id: s.skill_id,
|
||
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 Wechsel‑Pool. Überschüssige Auswahl wurde abgeschnitten.`,
|
||
)
|
||
nextIds = nextIds.slice(0, MAX_COMBO_CANDIDATES_PER_STATION)
|
||
}
|
||
setFormDirty(true)
|
||
setFormData((prev) => {
|
||
const rows = [...(prev.combination_slots || [])]
|
||
const row = rows[slotIdx] || emptyComboSlotRow()
|
||
const labels =
|
||
row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object' ? { ...row.exercise_title_by_id } : {}
|
||
pickedList.forEach((ex) => {
|
||
if (ex && ex.id != null) {
|
||
const id = Number(ex.id)
|
||
const t = (ex.title || '').trim()
|
||
if (t) labels[id] = t
|
||
}
|
||
})
|
||
rows[slotIdx] = { ...row, candidate_exercise_ids: nextIds, exercise_title_by_id: labels }
|
||
return { ...prev, combination_slots: rows }
|
||
})
|
||
}
|
||
|
||
const addSkillRow = () => {
|
||
const id = skillPick ? parseInt(skillPick, 10) : null
|
||
if (!id) {
|
||
toast.error('Fähigkeit wählen')
|
||
return
|
||
}
|
||
if (formData.skills.some((s) => s.skill_id === id)) {
|
||
toast.info('Bereits zugeordnet')
|
||
return
|
||
}
|
||
updateFormField('skills', [
|
||
...formData.skills,
|
||
{
|
||
skill_id: id,
|
||
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 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 & Planung empfohlen)</label>
|
||
<select
|
||
className="form-input"
|
||
value={formData.method_archetype || ''}
|
||
onChange={(e) => {
|
||
const arch = (e.target.value || '').trim()
|
||
const forced = ARCHETYPE_DEFAULT_REP_SERIES_COUNT[arch]
|
||
setFormDirty(true)
|
||
setFormData((prev) => {
|
||
const slots = prev.combination_slots || []
|
||
const nextSlots =
|
||
forced !== undefined && forced !== null
|
||
? slots.map((row) =>
|
||
normalizeAdvanceMode(row.advance_mode) !== 'timed'
|
||
? {
|
||
...row,
|
||
rep_series_count: String(Math.max(1, Math.round(Number(forced)))),
|
||
}
|
||
: row,
|
||
)
|
||
: slots
|
||
return { ...prev, method_archetype: arch, combination_slots: nextSlots }
|
||
})
|
||
}}
|
||
>
|
||
<option value="">— noch nicht festgelegt —</option>
|
||
{COMBINATION_ARCHETYPE_OPTIONS.map((o) => (
|
||
<option key={o.id} value={o.id}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
{String(formData.method_archetype || '').trim() === 'station_parcour' ? (
|
||
<p
|
||
style={{
|
||
fontSize: '12px',
|
||
color: 'var(--text2)',
|
||
margin: '4px 0 10px',
|
||
lineHeight: 1.48,
|
||
padding: '10px 12px',
|
||
borderRadius: '8px',
|
||
background: 'var(--surface)',
|
||
border: '1px solid var(--border)',
|
||
}}
|
||
>
|
||
<strong>Parcours / Bahnsystem:</strong> typischerweise starten alle an Station 1 und durchlaufen der
|
||
Reihe nach alle Punkte (Geschwindigkeit variabel). Die Stationsreihenfolge unten ist der Ablaufweg;
|
||
Zeitangaben pro Station und <strong>Gesamtdurchläufe</strong> im Ablaufprofil strukturieren das
|
||
spätere Coaching.
|
||
</p>
|
||
) : null}
|
||
<div style={{ paddingTop: '4px', borderTop: '1px dashed var(--border)', marginBottom: '12px' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: '8px', flexWrap: 'wrap' }}>
|
||
<strong style={{ fontSize: '14px' }}>Stationen</strong>
|
||
<span style={{ fontSize: '11px', color: 'var(--text3)' }}>
|
||
Ablauf = Reihenfolge · ziehen / Pfeile · Einzelübungen · max. {MAX_COMBO_CANDIDATES_PER_STATION}/Station
|
||
</span>
|
||
</div>
|
||
<p style={{ fontSize: '12px', color: 'var(--text2)', margin: '6px 0 12px', lineHeight: 1.48 }}>
|
||
Pro Station oft <strong>eine</strong> feste Übung; höchstens <strong>drei</strong> als kleiner Auswahl‑Pool.
|
||
Unter <strong>Steuerung</strong> wählen: zeitlich, nach Wiederholungszahl oder ohne Arbeitsuhr (Coach führt).
|
||
</p>
|
||
{(formData.combination_slots || []).map((row, idx) => {
|
||
const candIds = Array.isArray(row.candidate_exercise_ids) ? row.candidate_exercise_ids : []
|
||
const comboPoolFull = candIds.length >= MAX_COMBO_CANDIDATES_PER_STATION
|
||
const slotAdv = normalizeAdvanceMode(row.advance_mode)
|
||
const serieLabel =
|
||
slotAdv === 'timed' ? 'Serie' : slotAdv === 'rep' ? 'Wdh. / Serie' : 'Richtwert'
|
||
const seriePlaceholder = slotAdv === 'rep' ? '10' : slotAdv === 'manual' ? '–' : '1'
|
||
const showMultiSeries = slotAdv === 'rep' || slotAdv === 'manual'
|
||
const serienCountUi = parseComboRepSeriesCountUi(row.rep_series_count)
|
||
const showInterSeriesPause = showMultiSeries && serienCountUi >= 2
|
||
const intraLabel = slotAdv === 'timed' ? 'Pause (s)' : 'Pause zw. Serien'
|
||
const lbl =
|
||
row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object'
|
||
? row.exercise_title_by_id
|
||
: {}
|
||
const isDropHere = comboDropTargetIx === idx
|
||
return (
|
||
<div
|
||
key={`combo-slot-${idx}`}
|
||
onDragOver={(e) => {
|
||
if (!e.dataTransfer?.types?.includes?.(DND_EXERCISE_COMBO_STATION)) return
|
||
e.preventDefault()
|
||
e.dataTransfer.dropEffect = 'move'
|
||
setComboDropTargetIx(idx)
|
||
}}
|
||
onDragLeave={() => setComboDropTargetIx((cur) => (cur === idx ? null : cur))}
|
||
onDrop={(e) => {
|
||
const rawFrom = e.dataTransfer.getData(DND_EXERCISE_COMBO_STATION)
|
||
const fromI = parseInt(rawFrom, 10)
|
||
e.preventDefault()
|
||
setComboDropTargetIx(null)
|
||
if (!Number.isFinite(fromI)) return
|
||
reorderCombinationSlots(fromI, idx)
|
||
}}
|
||
style={{
|
||
marginBottom: '12px',
|
||
padding: '12px 14px',
|
||
borderRadius: '12px',
|
||
border: `1px solid ${isDropHere ? 'var(--accent)' : 'var(--border)'}`,
|
||
background: 'var(--surface)',
|
||
boxShadow: isDropHere ? '0 0 0 2px var(--accent-soft)' : 'none',
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '10px', alignItems: 'flex-start', marginBottom: '12px' }}>
|
||
<button
|
||
type="button"
|
||
draggable
|
||
onDragStart={(e) => {
|
||
e.dataTransfer.effectAllowed = 'move'
|
||
e.dataTransfer.setData(DND_EXERCISE_COMBO_STATION, String(idx))
|
||
}}
|
||
onDragEnd={() => setComboDropTargetIx(null)}
|
||
aria-label={`Station ${idx + 1} ziehen`}
|
||
title="Ziehen zum Sortieren"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
style={{ cursor: 'grab', padding: '6px 8px', touchAction: 'none' }}
|
||
>
|
||
<GripVertical size={18} strokeWidth={2} aria-hidden />
|
||
</button>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
aria-label="Station nach oben"
|
||
disabled={idx === 0}
|
||
onClick={() => reorderCombinationSlots(idx, idx - 1)}
|
||
>
|
||
▲
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
aria-label="Station nach unten"
|
||
disabled={idx === (formData.combination_slots || []).length - 1}
|
||
onClick={() => reorderCombinationSlots(idx, idx + 2)}
|
||
>
|
||
▼
|
||
</button>
|
||
</div>
|
||
<div className="form-row" style={{ flex: '1 1 200px', marginBottom: 0 }}>
|
||
<label className="form-label" style={{ fontSize: '12px' }}>
|
||
Name (St. {idx + 1})
|
||
</label>
|
||
<input
|
||
type="text"
|
||
className="form-input"
|
||
value={row.title || ''}
|
||
placeholder="z. B. Liegestütz"
|
||
onChange={(e) => patchComboSlotRow(idx, { title: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'flex-end' }}>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
disabled={comboPoolFull}
|
||
title={
|
||
comboPoolFull
|
||
? `Max. ${MAX_COMBO_CANDIDATES_PER_STATION} Übungen — eine entfernen, um weitere zu wählen.`
|
||
: 'Einzelübung zur Station hinzufügen'
|
||
}
|
||
onClick={() => setComboStationPickerIx(idx)}
|
||
>
|
||
+ Übung
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn framework-ctrl framework-ctrl--xs"
|
||
style={{ fontSize: '12px' }}
|
||
title="Diese Station entfernen"
|
||
onClick={() => {
|
||
const prev = formData.combination_slots || []
|
||
const next = prev.filter((_, j) => j !== idx)
|
||
updateFormField('combination_slots', next.length ? next : [emptyComboSlotRow()])
|
||
}}
|
||
>
|
||
Entfernen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div style={{ marginBottom: '12px' }}>
|
||
<span className="form-label" style={{ fontSize: '11px', display: 'block', marginBottom: '6px' }}>
|
||
Übungen ({candIds.length}/{MAX_COMBO_CANDIDATES_PER_STATION})
|
||
</span>
|
||
{candIds.length === 0 ? (
|
||
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>
|
||
Mindestens eine Übung — mit „+ Übung“ wählen.
|
||
</p>
|
||
) : (
|
||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||
{candIds.map((id) => (
|
||
<li
|
||
key={`${idx}-c-${id}`}
|
||
style={{
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
gap: '6px',
|
||
padding: '4px 10px',
|
||
borderRadius: '999px',
|
||
border: '1px solid var(--border)',
|
||
background: 'var(--surface2)',
|
||
fontSize: '12px',
|
||
}}
|
||
>
|
||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '14rem' }} title={`#${id}`}>
|
||
{(lbl[id] || lbl[String(id)] || '').trim() || `Übung #${id}`}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="tu-icon-btn"
|
||
aria-label={`Übung ${id} entfernen`}
|
||
title="Entfernen"
|
||
onClick={() => removeCandidateFromSlot(idx, id)}
|
||
>
|
||
✗
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
<div className="form-row" style={{ marginBottom: '8px', maxWidth: '22rem' }}>
|
||
<label className="form-label" style={{ fontSize: '11px' }}>
|
||
Steuerung
|
||
</label>
|
||
<select
|
||
className="form-input"
|
||
style={{ fontSize: '0.8125rem' }}
|
||
value={slotAdv}
|
||
onChange={(e) => {
|
||
const m = normalizeAdvanceMode(e.target.value)
|
||
const patch = { advance_mode: m }
|
||
if (m !== 'timed') patch.load_sec = ''
|
||
if (m === 'rep' || m === 'manual') {
|
||
const curSer = String(row.rep_series_count ?? '').trim()
|
||
if (!curSer) {
|
||
patch.rep_series_count = String(
|
||
defaultRepSeriesCountForArchetype(formData.method_archetype || ''),
|
||
)
|
||
}
|
||
}
|
||
patchComboSlotRow(idx, patch)
|
||
}}
|
||
>
|
||
<option value="timed">Zeit (Arbeit in Sekunden)</option>
|
||
<option value="rep">Wiederholungen (Ziel)</option>
|
||
<option value="manual">Coach (Weiter nach Freigabe)</option>
|
||
</select>
|
||
</div>
|
||
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '0 0 10px', lineHeight: 1.42 }}>
|
||
{slotAdv === 'timed'
|
||
? 'Arbeit (s): geplantes Ende nach Countdown möglich. Serie: Wiederholungen ohne Stationswechsel innerhalb einer Phase.'
|
||
: slotAdv === 'rep'
|
||
? 'Ohne Pflicht-Arbeits-Timer: Ziel über Wiederholungen. Ab zwei Serien: Pause zwischen diesen Serien; sonst nur Wechsel zur nächsten Station.'
|
||
: 'Coach: keine feste Arbeitsuhr — Fortschritt später per Tipp. Ab 2 Serien: Pause zwischen Serien; sonst nur Wechsel zur nächsten Station zeitlich planen.'}
|
||
</p>
|
||
<div
|
||
style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(auto-fill, minmax(5.25rem, 1fr))',
|
||
gap: '8px 10px',
|
||
alignItems: 'end',
|
||
}}
|
||
>
|
||
{slotAdv === 'timed' ? (
|
||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||
<label className="form-label" style={{ fontSize: '10px' }}>
|
||
Arbeit (s)
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
className="form-input"
|
||
style={comboTinyNumberInputSx}
|
||
placeholder="–"
|
||
value={row.load_sec || ''}
|
||
onChange={(e) => patchComboSlotRow(idx, { load_sec: e.target.value })}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||
<label className="form-label" style={{ fontSize: '10px' }}>
|
||
{serieLabel}
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min={slotAdv === 'rep' ? 1 : undefined}
|
||
className="form-input"
|
||
style={comboTinyNumberInputSx}
|
||
placeholder={seriePlaceholder}
|
||
value={row.consecutive_reps || ''}
|
||
onChange={(e) => patchComboSlotRow(idx, { consecutive_reps: e.target.value })}
|
||
/>
|
||
</div>
|
||
{showMultiSeries ? (
|
||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||
<label className="form-label" style={{ fontSize: '10px' }} title="Wie oft die angegebene Wdh.-Zahl hintereinander (mit Pause zw. Serien)?">
|
||
Serien
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min={slotAdv === 'rep' ? 1 : undefined}
|
||
className="form-input"
|
||
style={comboTinyNumberInputSx}
|
||
placeholder="1"
|
||
value={row.rep_series_count || ''}
|
||
onChange={(e) => {
|
||
let rawSer = e.target.value.trim()
|
||
if (rawSer === '') rawSer = '1'
|
||
const pn = parseInt(String(rawSer).trim(), 10)
|
||
const patch = { rep_series_count: rawSer }
|
||
if (!Number.isFinite(pn) || pn < 2) patch.intra_rep_rest_sec = ''
|
||
patchComboSlotRow(idx, patch)
|
||
}}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
{slotAdv === 'timed' || showInterSeriesPause ? (
|
||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||
<label className="form-label" style={{ fontSize: '10px' }}>
|
||
{intraLabel}
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
className="form-input"
|
||
style={comboTinyNumberInputSx}
|
||
placeholder="–"
|
||
value={row.intra_rep_rest_sec || ''}
|
||
onChange={(e) => patchComboSlotRow(idx, { intra_rep_rest_sec: e.target.value })}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||
<label className="form-label" style={{ fontSize: '10px' }}>
|
||
Wechsel (s)
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
className="form-input"
|
||
style={comboTinyNumberInputSx}
|
||
placeholder="–"
|
||
value={row.transition_after_sec || ''}
|
||
onChange={(e) => patchComboSlotRow(idx, { transition_after_sec: e.target.value })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
{showMultiSeries && serienCountUi < 2 ? (
|
||
<p style={{ fontSize: '10px', color: 'var(--text3)', margin: '6px 0 0', lineHeight: 1.38 }}>
|
||
<strong>Wechsel (s)</strong> = Pause bis zur <strong>nächsten Station</strong>. Feld „Pause zw.
|
||
Serien“ erscheint erst ab 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 & 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
|