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, '"') } /** 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) => `

${escapeHtmlText(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 ( <>
onPatch({ variant_name: e.target.value })} minLength={3} />