2026-05-20 07:42:46 +02:00
|
|
|
|
import React, { useEffect, useMemo, useState } from 'react'
|
|
|
|
|
|
import { useParams, useLocation } from 'react-router-dom'
|
2026-04-27 14:27:25 +02:00
|
|
|
|
import api from '../utils/api'
|
2026-05-20 07:42:46 +02:00
|
|
|
|
import PageReturnButton from '../components/PageReturnButton'
|
|
|
|
|
|
import NavStateLink from '../components/NavStateLink'
|
|
|
|
|
|
import {
|
|
|
|
|
|
EXERCISES_LIST_PATH,
|
|
|
|
|
|
buildCurrentLocationReturnContext,
|
|
|
|
|
|
} from '../utils/navReturnContext'
|
2026-05-08 11:44:29 +02:00
|
|
|
|
import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
|
2026-05-08 12:20:24 +02:00
|
|
|
|
import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaStrip'
|
2026-05-13 21:41:32 +02:00
|
|
|
|
import CombinationPlanBracket from '../components/CombinationPlanBracket'
|
2026-05-13 21:58:24 +02:00
|
|
|
|
import ExercisePeekModal from '../components/ExercisePeekModal'
|
2026-04-27 18:25:23 +02:00
|
|
|
|
import { formatSkillLevelSlug } from '../constants/skillLevels'
|
2026-04-27 14:27:25 +02:00
|
|
|
|
|
2026-04-27 14:48:46 +02:00
|
|
|
|
function TagRow({ exercise }) {
|
|
|
|
|
|
const tags = []
|
|
|
|
|
|
;(exercise.focus_areas || []).forEach((f) => {
|
|
|
|
|
|
tags.push({ key: `fa-${f.id}`, label: f.name, accent: !!f.is_primary })
|
|
|
|
|
|
})
|
|
|
|
|
|
;(exercise.training_styles || []).forEach((t) => {
|
|
|
|
|
|
tags.push({ key: `ts-${t.id}`, label: t.name, accent: false })
|
|
|
|
|
|
})
|
|
|
|
|
|
;(exercise.training_types || []).forEach((t) => {
|
|
|
|
|
|
tags.push({ key: `tt-${t.id}`, label: t.name, accent: false })
|
|
|
|
|
|
})
|
|
|
|
|
|
;(exercise.target_groups || []).forEach((g) => {
|
|
|
|
|
|
tags.push({ key: `tg-${g.id}`, label: g.name, accent: !!g.is_primary })
|
|
|
|
|
|
})
|
|
|
|
|
|
if (tags.length === 0) return null
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="exercise-tag-row">
|
|
|
|
|
|
{tags.map((t) => (
|
|
|
|
|
|
<span key={t.key} className={`exercise-tag${t.accent ? ' exercise-tag--accent' : ''}`}>
|
|
|
|
|
|
{t.label}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function metaParts(exercise) {
|
|
|
|
|
|
const parts = []
|
|
|
|
|
|
if (exercise.duration_min != null || exercise.duration_max != null) {
|
|
|
|
|
|
const a = exercise.duration_min
|
|
|
|
|
|
const b = exercise.duration_max
|
|
|
|
|
|
if (a != null && b != null && a !== b) parts.push(`${a}–${b} Min.`)
|
|
|
|
|
|
else if (a != null) parts.push(`ca. ${a} Min.`)
|
|
|
|
|
|
else if (b != null) parts.push(`ca. ${b} Min.`)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (exercise.group_size_min != null || exercise.group_size_max != null) {
|
|
|
|
|
|
const a = exercise.group_size_min
|
|
|
|
|
|
const b = exercise.group_size_max
|
|
|
|
|
|
if (a != null && b != null && a !== b) parts.push(`Gruppe ${a}–${b}`)
|
|
|
|
|
|
else if (a != null) parts.push(`Gruppe ab ${a}`)
|
|
|
|
|
|
else if (b != null) parts.push(`Gruppe bis ${b}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
return parts
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-27 14:27:25 +02:00
|
|
|
|
function ExerciseDetailPage() {
|
|
|
|
|
|
const { id } = useParams()
|
2026-05-08 12:35:28 +02:00
|
|
|
|
const location = useLocation()
|
2026-05-20 07:42:46 +02:00
|
|
|
|
const editReturnContext = useMemo(
|
|
|
|
|
|
() => buildCurrentLocationReturnContext(location, 'Zurück zur Übung'),
|
|
|
|
|
|
[location]
|
|
|
|
|
|
)
|
2026-04-27 14:27:25 +02:00
|
|
|
|
const [exercise, setExercise] = useState(null)
|
|
|
|
|
|
const [error, setError] = useState(null)
|
|
|
|
|
|
const [loading, setLoading] = useState(true)
|
2026-05-13 21:58:24 +02:00
|
|
|
|
/** Schnellansicht für eingebettete Einzelübungen (Kombination) — ohne Route zu verlassen */
|
|
|
|
|
|
const [embeddedPeekExerciseId, setEmbeddedPeekExerciseId] = useState(null)
|
2026-04-27 14:27:25 +02:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
const load = async () => {
|
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
setError(null)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await api.getExercise(id)
|
|
|
|
|
|
if (!cancelled) setExercise(data)
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (!cancelled) setError(err)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (!cancelled) setLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (id) load()
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [id])
|
|
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
|
|
|
|
|
<div className="spinner"></div>
|
|
|
|
|
|
<p>Laden...</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
|
const msg = error.message || String(error)
|
|
|
|
|
|
return (
|
2026-05-05 12:41:59 +02:00
|
|
|
|
<div style={{ padding: '1rem' }} className="app-page">
|
2026-04-27 14:27:25 +02:00
|
|
|
|
<div className="card">
|
|
|
|
|
|
<h2>Übung</h2>
|
|
|
|
|
|
<p style={{ color: 'var(--danger)' }}>{msg}</p>
|
2026-05-20 07:42:46 +02:00
|
|
|
|
<PageReturnButton
|
|
|
|
|
|
fallbackPath={EXERCISES_LIST_PATH}
|
|
|
|
|
|
fallbackLabel="Zurück zur Übungsliste"
|
|
|
|
|
|
/>
|
2026-04-27 14:27:25 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!exercise) return null
|
|
|
|
|
|
|
2026-04-27 14:48:46 +02:00
|
|
|
|
const meta = metaParts(exercise)
|
2026-04-27 14:27:25 +02:00
|
|
|
|
|
2026-05-13 21:41:32 +02:00
|
|
|
|
const isCombinationDetail =
|
|
|
|
|
|
(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' &&
|
|
|
|
|
|
Array.isArray(exercise.combination_slots) &&
|
|
|
|
|
|
exercise.combination_slots.length > 0
|
|
|
|
|
|
const catalogMethodProfileForBracket =
|
|
|
|
|
|
exercise.method_profile &&
|
|
|
|
|
|
typeof exercise.method_profile === 'object' &&
|
|
|
|
|
|
!Array.isArray(exercise.method_profile)
|
|
|
|
|
|
? exercise.method_profile
|
|
|
|
|
|
: {}
|
|
|
|
|
|
|
2026-04-27 14:27:25 +02:00
|
|
|
|
return (
|
2026-04-27 14:48:46 +02:00
|
|
|
|
<div className="exercise-detail-shell" style={{ padding: '12px 12px 24px' }}>
|
2026-05-13 21:58:24 +02:00
|
|
|
|
<ExercisePeekModal
|
|
|
|
|
|
key={embeddedPeekExerciseId != null ? String(embeddedPeekExerciseId) : 'exercise-detail-peek'}
|
|
|
|
|
|
open={embeddedPeekExerciseId != null}
|
|
|
|
|
|
exerciseId={embeddedPeekExerciseId}
|
|
|
|
|
|
onClose={() => setEmbeddedPeekExerciseId(null)}
|
|
|
|
|
|
/>
|
2026-05-08 12:35:28 +02:00
|
|
|
|
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
|
2026-05-20 07:42:46 +02:00
|
|
|
|
<PageReturnButton
|
|
|
|
|
|
fallbackPath={EXERCISES_LIST_PATH}
|
|
|
|
|
|
fallbackLabel="Zurück zur Übungsliste"
|
|
|
|
|
|
className="page-return-btn btn btn-secondary btn-small"
|
|
|
|
|
|
/>
|
2026-05-08 12:35:28 +02:00
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginLeft: 'auto' }}>
|
2026-05-20 07:42:46 +02:00
|
|
|
|
<NavStateLink
|
|
|
|
|
|
to={`/exercises/${exercise.id}/edit`}
|
|
|
|
|
|
returnContext={editReturnContext}
|
|
|
|
|
|
className="btn btn-primary"
|
|
|
|
|
|
>
|
|
|
|
|
|
Bearbeiten
|
|
|
|
|
|
</NavStateLink>
|
2026-05-08 12:35:28 +02:00
|
|
|
|
</div>
|
2026-04-27 14:48:46 +02:00
|
|
|
|
</div>
|
2026-04-27 14:27:25 +02:00
|
|
|
|
|
2026-04-27 14:48:46 +02:00
|
|
|
|
<div className="card exercise-detail-section">
|
|
|
|
|
|
<h1 style={{ margin: 0, fontSize: '1.35rem', lineHeight: 1.25 }}>{exercise.title}</h1>
|
|
|
|
|
|
{exercise.summary && (
|
|
|
|
|
|
<div style={{ marginTop: '10px', color: 'var(--text2)', fontSize: '15px' }}>
|
2026-05-08 11:44:29 +02:00
|
|
|
|
<ExerciseRichTextBlock html={exercise.summary} exerciseId={exercise.id} media={exercise.media} />
|
2026-04-27 14:27:25 +02:00
|
|
|
|
</div>
|
2026-04-27 14:48:46 +02:00
|
|
|
|
)}
|
|
|
|
|
|
<TagRow exercise={exercise} />
|
|
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginTop: '10px' }}>
|
|
|
|
|
|
<span className="badge">{exercise.visibility}</span>
|
|
|
|
|
|
<span className="badge">{exercise.status}</span>
|
|
|
|
|
|
{exercise.club_name && <span className="badge">{exercise.club_name}</span>}
|
2026-04-27 14:27:25 +02:00
|
|
|
|
</div>
|
2026-04-27 14:48:46 +02:00
|
|
|
|
{meta.length > 0 && <p className="exercise-meta-line">{meta.join(' · ')}</p>}
|
|
|
|
|
|
</div>
|
2026-04-27 14:27:25 +02:00
|
|
|
|
|
2026-05-13 21:41:32 +02:00
|
|
|
|
{isCombinationDetail ? (
|
|
|
|
|
|
<section className="card exercise-detail-section">
|
|
|
|
|
|
<h2>Ablauf und Stationen</h2>
|
|
|
|
|
|
<p style={{ marginTop: 0, fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45 }}>
|
2026-05-13 21:58:24 +02:00
|
|
|
|
Katalog‑Ablauf mit Archetyp, Zeiten und Stationen. Station bzw. Einzelübung antippen öffnet eine
|
|
|
|
|
|
Schnellansicht mit Kurztext und Ablauf, ohne diese Seite zu verlassen. Die vollständige Übungsseite
|
|
|
|
|
|
liegt im Popup unten als Link.
|
2026-05-13 21:41:32 +02:00
|
|
|
|
</p>
|
|
|
|
|
|
<div className="training-run-combo-embed">
|
|
|
|
|
|
<CombinationPlanBracket
|
|
|
|
|
|
methodArchetype={String(exercise.method_archetype || '').trim()}
|
|
|
|
|
|
methodProfile={catalogMethodProfileForBracket}
|
|
|
|
|
|
combinationSlots={exercise.combination_slots}
|
|
|
|
|
|
planningAdjusted={false}
|
2026-05-13 21:58:24 +02:00
|
|
|
|
candidateInteraction="button"
|
|
|
|
|
|
onCandidatePeek={(exerciseId) => setEmbeddedPeekExerciseId(Number(exerciseId))}
|
2026-05-13 21:41:32 +02:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
) : null}
|
2026-05-13 06:12:47 +02:00
|
|
|
|
|
2026-04-27 14:48:46 +02:00
|
|
|
|
{exercise.goal && (
|
|
|
|
|
|
<section className="card exercise-detail-section">
|
|
|
|
|
|
<h2>Ziel</h2>
|
2026-05-08 11:44:29 +02:00
|
|
|
|
<ExerciseRichTextBlock html={exercise.goal} exerciseId={exercise.id} media={exercise.media} />
|
2026-04-27 14:27:25 +02:00
|
|
|
|
</section>
|
2026-04-27 14:48:46 +02:00
|
|
|
|
)}
|
2026-04-27 14:27:25 +02:00
|
|
|
|
|
2026-04-27 14:48:46 +02:00
|
|
|
|
{(exercise.equipment || []).length > 0 && (
|
|
|
|
|
|
<section className="card exercise-detail-section">
|
|
|
|
|
|
<h2>Material & Aufbau</h2>
|
|
|
|
|
|
<ul style={{ paddingLeft: '1.2rem', margin: 0 }}>
|
|
|
|
|
|
{exercise.equipment.map((x, i) => (
|
|
|
|
|
|
<li key={i}>{x}</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
)}
|
2026-04-27 14:27:25 +02:00
|
|
|
|
|
2026-04-27 14:48:46 +02:00
|
|
|
|
{exercise.preparation && (
|
|
|
|
|
|
<section className="card exercise-detail-section">
|
|
|
|
|
|
<h2>Vorbereitung</h2>
|
2026-05-08 11:44:29 +02:00
|
|
|
|
<ExerciseRichTextBlock html={exercise.preparation} exerciseId={exercise.id} media={exercise.media} />
|
2026-04-27 14:48:46 +02:00
|
|
|
|
</section>
|
|
|
|
|
|
)}
|
2026-04-27 14:27:25 +02:00
|
|
|
|
|
2026-04-27 14:48:46 +02:00
|
|
|
|
{exercise.execution && (
|
|
|
|
|
|
<section className="card exercise-detail-section">
|
|
|
|
|
|
<h2>Ablauf</h2>
|
2026-05-08 11:44:29 +02:00
|
|
|
|
<ExerciseRichTextBlock html={exercise.execution} exerciseId={exercise.id} media={exercise.media} />
|
2026-04-27 14:48:46 +02:00
|
|
|
|
</section>
|
|
|
|
|
|
)}
|
2026-04-27 14:27:25 +02:00
|
|
|
|
|
2026-05-08 12:20:24 +02:00
|
|
|
|
<ExerciseAttachmentMediaStrip exercise={exercise} exerciseId={exercise.id} />
|
2026-04-27 14:27:25 +02:00
|
|
|
|
|
2026-04-27 14:48:46 +02:00
|
|
|
|
{exercise.trainer_notes && (
|
|
|
|
|
|
<section className="card exercise-detail-section">
|
|
|
|
|
|
<h2>Hinweise für Trainer</h2>
|
2026-05-08 11:44:29 +02:00
|
|
|
|
<ExerciseRichTextBlock html={exercise.trainer_notes} exerciseId={exercise.id} media={exercise.media} />
|
2026-04-27 14:48:46 +02:00
|
|
|
|
</section>
|
|
|
|
|
|
)}
|
2026-04-27 14:27:25 +02:00
|
|
|
|
|
2026-04-27 14:48:46 +02:00
|
|
|
|
{(exercise.skills || []).length > 0 && (
|
|
|
|
|
|
<section className="card exercise-detail-section">
|
|
|
|
|
|
<h2>Fähigkeiten</h2>
|
|
|
|
|
|
<div className="exercise-tag-row">
|
2026-04-27 18:25:23 +02:00
|
|
|
|
{exercise.skills.map((s) => {
|
|
|
|
|
|
const rl = formatSkillLevelSlug(s.required_level)
|
|
|
|
|
|
const tl = formatSkillLevelSlug(s.target_level)
|
|
|
|
|
|
const lvl =
|
|
|
|
|
|
rl || tl ? ` (${[rl, tl].filter(Boolean).join(' → ')})` : ''
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span key={s.id} className={`exercise-tag${s.is_primary ? ' exercise-tag--accent' : ''}`}>
|
|
|
|
|
|
{s.skill_name}
|
|
|
|
|
|
{s.intensity ? ` · ${s.intensity}` : ''}
|
|
|
|
|
|
{lvl}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
2026-04-27 14:48:46 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
)}
|
2026-04-27 14:27:25 +02:00
|
|
|
|
|
2026-04-27 14:48:46 +02:00
|
|
|
|
{(exercise.variants || []).length > 0 && (
|
|
|
|
|
|
<section className="card exercise-detail-section">
|
|
|
|
|
|
<h2>Varianten</h2>
|
2026-04-28 10:59:09 +02:00
|
|
|
|
{exercise.variants.map((v) => {
|
|
|
|
|
|
const dur =
|
|
|
|
|
|
v.duration_min != null || v.duration_max != null
|
|
|
|
|
|
? v.duration_min != null && v.duration_max != null && v.duration_min !== v.duration_max
|
|
|
|
|
|
? `${v.duration_min}–${v.duration_max} Min.`
|
|
|
|
|
|
: v.duration_min != null
|
|
|
|
|
|
? `ca. ${v.duration_min} Min.`
|
|
|
|
|
|
: `bis ca. ${v.duration_max} Min.`
|
|
|
|
|
|
: null
|
|
|
|
|
|
const diffLabel =
|
|
|
|
|
|
v.difficulty_adjustment === 'easier'
|
|
|
|
|
|
? 'einfacher'
|
|
|
|
|
|
: v.difficulty_adjustment === 'harder'
|
|
|
|
|
|
? 'schwerer'
|
|
|
|
|
|
: v.difficulty_adjustment === 'same'
|
|
|
|
|
|
? 'gleiche Schwierigkeit'
|
|
|
|
|
|
: v.difficulty_adjustment === 'adapted'
|
|
|
|
|
|
? 'angepasst'
|
|
|
|
|
|
: null
|
|
|
|
|
|
const equip =
|
|
|
|
|
|
Array.isArray(v.equipment_changes) && v.equipment_changes.length > 0
|
|
|
|
|
|
? v.equipment_changes.join(', ')
|
|
|
|
|
|
: null
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={v.id}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
marginBottom: '1rem',
|
|
|
|
|
|
paddingBottom: '1rem',
|
|
|
|
|
|
borderBottom: '1px solid var(--border)',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<strong style={{ fontSize: '15px' }}>{v.variant_name}</strong>
|
|
|
|
|
|
{(dur || diffLabel || equip || v.progression_level != null) && (
|
|
|
|
|
|
<div style={{ fontSize: '13px', color: 'var(--text3)', marginTop: '4px' }}>
|
|
|
|
|
|
{[dur, diffLabel, equip && `Material: ${equip}`, v.progression_level != null && `Progression ${v.progression_level}`]
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.join(' · ')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{v.description && <p style={{ color: 'var(--text2)', marginTop: '4px' }}>{v.description}</p>}
|
|
|
|
|
|
{v.execution_changes && (
|
|
|
|
|
|
<div style={{ marginTop: '8px' }}>
|
2026-05-08 11:44:29 +02:00
|
|
|
|
<ExerciseRichTextBlock
|
|
|
|
|
|
html={v.execution_changes}
|
|
|
|
|
|
exerciseId={exercise.id}
|
|
|
|
|
|
media={exercise.media}
|
|
|
|
|
|
/>
|
2026-04-28 10:59:09 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
2026-04-27 14:48:46 +02:00
|
|
|
|
</section>
|
|
|
|
|
|
)}
|
2026-04-27 14:27:25 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default ExerciseDetailPage
|