feat(combo-planning): enhance candidate interaction and UI for combination exercises
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 57s
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 57s
- Introduced new CSS styles for interactive candidate buttons and links in the `CombinationPlanBracket` and `CombinationCoachSlots` components, improving user engagement. - Updated `CombinationPlanBracket` to conditionally render candidates as buttons or links based on interaction type, enhancing navigation options. - Refactored candidate handling in `CombinationCoachSlots` to support new interaction methods, streamlining candidate exercise display. - Enhanced `ExercisePeekModal` and related components to support candidate peek functionality, allowing for a more seamless user experience. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
00edc7a93d
commit
502dddd3b3
|
|
@ -6398,6 +6398,68 @@ a.analysis-split__nav-item {
|
|||
color: var(--text3);
|
||||
margin-right: 6px;
|
||||
}
|
||||
.combo-plan-bracket__station-exercises--interactive {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px 6px;
|
||||
}
|
||||
.combo-plan-bracket__cand-inline {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
.combo-plan-bracket__cand-sep {
|
||||
color: var(--text3);
|
||||
font-size: 0.78rem;
|
||||
user-select: none;
|
||||
}
|
||||
.combo-plan-bracket__cand-btn {
|
||||
margin: 0;
|
||||
padding: 2px 8px;
|
||||
font: inherit;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-dark);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.combo-plan-bracket__cand-btn:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--surface2);
|
||||
}
|
||||
.combo-plan-bracket__cand-link {
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-dark);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.combo-plan-bracket__cand-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
button.combo-coach-cand-link {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
button.combo-coach-cand-link:hover {
|
||||
color: var(--accent-dark);
|
||||
}
|
||||
|
||||
.training-run-combo-embed {
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,17 +35,28 @@ export default function CombinationCoachSlots({
|
|||
methodProfile,
|
||||
compactPlanningView = false,
|
||||
omitGlobalKeyValueBlock = false,
|
||||
/** Wenn gesetzt: Kandidaten als Button → Peek (kein Router-Wechsel, PWA-sicher) */
|
||||
onOpenCandidatePeek,
|
||||
}) {
|
||||
const slots = useMemo(() => sortCombinationSlotsForDisplay(combinationSlots), [combinationSlots])
|
||||
|
||||
const candidateIds = useMemo(() => {
|
||||
const set = new Set()
|
||||
for (const s of slots) {
|
||||
if (Array.isArray(s.candidates) && s.candidates.length) {
|
||||
for (const c of s.candidates) {
|
||||
const raw = c.exercise_id
|
||||
if (raw == null) continue
|
||||
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
|
||||
if (Number.isFinite(n)) set.add(n)
|
||||
}
|
||||
} else {
|
||||
for (const id of s.candidate_exercise_ids || []) {
|
||||
const n = typeof id === 'number' ? id : parseInt(String(id), 10)
|
||||
if (Number.isFinite(n)) set.add(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...set]
|
||||
}, [slots])
|
||||
|
||||
|
|
@ -282,9 +293,19 @@ export default function CombinationCoachSlots({
|
|||
<>
|
||||
<p style={{ margin: '0 0 4px', fontSize: '0.96rem', fontWeight: 700 }}>{ex.title}</p>
|
||||
<p style={{ margin: 0 }}>
|
||||
{typeof onOpenCandidatePeek === 'function' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="combo-coach-cand-link"
|
||||
onClick={() => onOpenCandidatePeek(cid)}
|
||||
>
|
||||
Details anzeigen
|
||||
</button>
|
||||
) : (
|
||||
<Link to={`/exercises/${cid}`} style={{ fontSize: '0.82rem', color: 'var(--accent)' }}>
|
||||
Im Katalog öffnen
|
||||
</Link>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -320,9 +341,19 @@ export default function CombinationCoachSlots({
|
|||
</details>
|
||||
) : null}
|
||||
<p style={{ marginTop: '8px', marginBottom: 0 }}>
|
||||
{typeof onOpenCandidatePeek === 'function' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="combo-coach-cand-link"
|
||||
onClick={() => onOpenCandidatePeek(cid)}
|
||||
>
|
||||
Volle Übungsansicht
|
||||
</button>
|
||||
) : (
|
||||
<Link to={`/exercises/${cid}`} style={{ fontSize: '0.84rem', color: 'var(--accent)' }}>
|
||||
Volle Übungsseite
|
||||
</Link>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
* Kombination: konsolidierte Darstellung globales Profil + Stationen mit Zeiten (Vorschau, Plan-Ansicht, Druck).
|
||||
*/
|
||||
import React, { useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
archetypeCoachHint,
|
||||
combinationArchetypeLabel,
|
||||
|
|
@ -14,24 +15,22 @@ import {
|
|||
stationPrimaryLoadLabel,
|
||||
} from '../utils/combinationMethodProfileUi'
|
||||
|
||||
function candidateLine(slot) {
|
||||
const cands = slot.candidates
|
||||
if (Array.isArray(cands) && cands.length > 0) {
|
||||
return cands
|
||||
.map((c) =>
|
||||
((c.title || '').trim() || (c.exercise_id != null ? `Übung #${c.exercise_id}` : '')).trim(),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(' ↔ ')
|
||||
/** @returns {{ exerciseId: number, label: string }[]} */
|
||||
export function normalizeCombinationSlotCandidates(slot) {
|
||||
const out = []
|
||||
const cands =
|
||||
slot.candidates && slot.candidates.length
|
||||
? slot.candidates
|
||||
: (slot.candidate_exercise_ids || []).map((id) => ({ exercise_id: id, title: null }))
|
||||
for (const c of cands) {
|
||||
const rawId = c.exercise_id
|
||||
if (rawId == null) continue
|
||||
const n = typeof rawId === 'number' ? rawId : parseInt(String(rawId), 10)
|
||||
if (!Number.isFinite(n)) continue
|
||||
const label = ((c.title || '').trim() || `Übung #${n}`).trim()
|
||||
out.push({ exerciseId: n, label })
|
||||
}
|
||||
const ids = slot.candidate_exercise_ids || []
|
||||
return ids
|
||||
.map((raw) => {
|
||||
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
|
||||
return Number.isFinite(n) ? `Übung #${n}` : ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' ↔ ')
|
||||
return out
|
||||
}
|
||||
|
||||
export default function CombinationPlanBracket({
|
||||
|
|
@ -39,6 +38,9 @@ export default function CombinationPlanBracket({
|
|||
methodProfile,
|
||||
combinationSlots,
|
||||
planningAdjusted = false,
|
||||
/** 'none' | 'link' (Router) | 'button' (z. B. ExercisePeekModal / PWA-sicher) */
|
||||
candidateInteraction = 'none',
|
||||
onCandidatePeek,
|
||||
}) {
|
||||
const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : ''
|
||||
const archLabel = arch ? combinationArchetypeLabel(arch) : null
|
||||
|
|
@ -97,7 +99,8 @@ export default function CombinationPlanBracket({
|
|||
const stationIx = Number.isFinite(ixParsed) ? ixParsed : si
|
||||
const displayStep = si + 1
|
||||
const stationTitle = ((slot.title || '').trim() || `Station ${displayStep}`).trim()
|
||||
const names = candidateLine(slot)
|
||||
const candRows = normalizeCombinationSlotCandidates(slot)
|
||||
const names = candRows.length ? candRows.map((r) => r.label).join(' ↔ ') : ''
|
||||
const slotProfRow = timingByIx.get(stationIx)
|
||||
const loadBadge = stationPrimaryLoadLabel(slotProfRow)
|
||||
const timing = effectiveStationTimingSummary(arch, methodProfile || {}, slotProfRow)
|
||||
|
|
@ -112,7 +115,35 @@ export default function CombinationPlanBracket({
|
|||
</div>
|
||||
<div className="combo-plan-bracket__station-main">
|
||||
<div className="combo-plan-bracket__station-title">{stationTitle}</div>
|
||||
{candidateInteraction === 'button' && typeof onCandidatePeek === 'function' && candRows.length > 0 ? (
|
||||
<div className="combo-plan-bracket__station-exercises combo-plan-bracket__station-exercises--interactive">
|
||||
{candRows.map((c, ci) => (
|
||||
<span key={`${c.exerciseId}-${ci}`} className="combo-plan-bracket__cand-inline">
|
||||
{ci > 0 ? <span className="combo-plan-bracket__cand-sep">↔</span> : null}
|
||||
<button
|
||||
type="button"
|
||||
className="combo-plan-bracket__cand-btn"
|
||||
onClick={() => onCandidatePeek(c.exerciseId, c.label)}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : candidateInteraction === 'link' && candRows.length > 0 ? (
|
||||
<div className="combo-plan-bracket__station-exercises combo-plan-bracket__station-exercises--interactive">
|
||||
{candRows.map((c, ci) => (
|
||||
<span key={`${c.exerciseId}-${ci}`} className="combo-plan-bracket__cand-inline">
|
||||
{ci > 0 ? <span className="combo-plan-bracket__cand-sep">↔</span> : null}
|
||||
<Link to={`/exercises/${c.exerciseId}`} className="combo-plan-bracket__cand-link">
|
||||
{c.label}
|
||||
</Link>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="combo-plan-bracket__station-exercises">{names || '(keine Einzelübung)'}</div>
|
||||
)}
|
||||
{timing ? (
|
||||
<div className="combo-plan-bracket__station-timing">
|
||||
<span className="combo-plan-bracket__timing-label">Zeit / Steuerung</span>
|
||||
|
|
|
|||
|
|
@ -54,9 +54,18 @@ function metaParts(exercise) {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param {{ exercise?: object|null, loading?: boolean, error?: string|null, exerciseId?: number, variantId?: number|string|null, planningComboMethodProfile?: object|null, catalogMethodProfileSnapshot?: object|null }} props
|
||||
* @param {{ exercise?: object|null, loading?: boolean, error?: string|null, exerciseId?: number, variantId?: number|string|null, planningComboMethodProfile?: object|null, catalogMethodProfileSnapshot?: object|null, onCandidateExercisePeek?: (exerciseId: number) => void }} props
|
||||
*/
|
||||
export default function ExerciseFullContent({ exercise, loading, error, exerciseId, variantId, planningComboMethodProfile, catalogMethodProfileSnapshot }) {
|
||||
export default function ExerciseFullContent({
|
||||
exercise,
|
||||
loading,
|
||||
error,
|
||||
exerciseId,
|
||||
variantId,
|
||||
planningComboMethodProfile,
|
||||
catalogMethodProfileSnapshot,
|
||||
onCandidateExercisePeek,
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '1rem' }}>
|
||||
|
|
@ -129,6 +138,7 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
|
|||
combinationSlots={exercise.combination_slots}
|
||||
methodArchetype={exercise.method_archetype}
|
||||
methodProfile={coachComboProfile}
|
||||
onOpenCandidatePeek={onCandidateExercisePeek}
|
||||
/>
|
||||
) : null}
|
||||
<h2 style={{ margin: '0 0 8px', fontSize: '1.2rem', lineHeight: 1.35 }}>{exercise.title}</h2>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
/**
|
||||
* Schnellansicht einer Übung aus dem Katalog (ohne die Planungsseite zu verlassen).
|
||||
* Unterstützt Drill-down zu Kandidaten-Übungen bei Kombinationen inkl. „Zurück“ (PWA-sicher).
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
|
||||
|
|
@ -25,6 +26,8 @@ function TagMini({ exercise }) {
|
|||
)
|
||||
}
|
||||
|
||||
/** @typedef {{ exerciseId: number, variantId?: number | null, peekExtras?: object | null }} PeekStackEntry */
|
||||
|
||||
export default function ExercisePeekModal({
|
||||
open,
|
||||
exerciseId,
|
||||
|
|
@ -37,36 +40,37 @@ export default function ExercisePeekModal({
|
|||
const [loading, setLoading] = useState(false)
|
||||
const [err, setErr] = useState(null)
|
||||
const [exercise, setExercise] = useState(null)
|
||||
/** @type {[PeekStackEntry[], React.Dispatch<React.SetStateAction<PeekStackEntry[]>>]} */
|
||||
const [stack, setStack] = useState([])
|
||||
|
||||
const variant =
|
||||
variantId != null && variantId !== '' && exercise?.variants?.length
|
||||
? exercise.variants.find((v) => String(v.id) === String(variantId)) || null
|
||||
: null
|
||||
|
||||
const isCombination =
|
||||
exercise &&
|
||||
String(exercise.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
|
||||
|
||||
const comboMethodProfileEffective = useMemo(() => {
|
||||
if (!exercise || !isCombination) return {}
|
||||
const fromPeek =
|
||||
peekExtras?.catalog_method_profile &&
|
||||
typeof peekExtras.catalog_method_profile === 'object' &&
|
||||
!Array.isArray(peekExtras.catalog_method_profile) &&
|
||||
Object.keys(peekExtras.catalog_method_profile).length > 0
|
||||
? peekExtras.catalog_method_profile
|
||||
: exercise.method_profile || {}
|
||||
return effectiveComboMethodProfile(fromPeek, peekExtras?.planning_method_profile ?? null)
|
||||
}, [exercise, isCombination, peekExtras])
|
||||
/** @type {React.MutableRefObject<boolean>} */
|
||||
const wasOpenRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setExercise(null)
|
||||
setErr(null)
|
||||
setStack([])
|
||||
wasOpenRef.current = false
|
||||
return
|
||||
}
|
||||
if (!exerciseId) {
|
||||
setErr('Keine Übung gewählt')
|
||||
if (exerciseId == null || exerciseId === '') return
|
||||
if (!wasOpenRef.current) {
|
||||
wasOpenRef.current = true
|
||||
setStack([
|
||||
{
|
||||
exerciseId: Number(exerciseId),
|
||||
variantId: variantId ?? null,
|
||||
peekExtras: peekExtras ?? null,
|
||||
},
|
||||
])
|
||||
}
|
||||
}, [open, exerciseId, variantId, peekExtras])
|
||||
|
||||
const top = stack.length ? stack[stack.length - 1] : null
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !top?.exerciseId) {
|
||||
setExercise(null)
|
||||
setErr(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
|
|
@ -74,7 +78,7 @@ export default function ExercisePeekModal({
|
|||
setLoading(true)
|
||||
setErr(null)
|
||||
try {
|
||||
const data = await api.getExercise(exerciseId)
|
||||
const data = await api.getExercise(top.exerciseId)
|
||||
if (!cancelled) setExercise(data)
|
||||
} catch (e) {
|
||||
if (!cancelled) setErr(e.message || 'Laden fehlgeschlagen')
|
||||
|
|
@ -85,7 +89,40 @@ export default function ExercisePeekModal({
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, exerciseId, variantId])
|
||||
}, [open, top?.exerciseId])
|
||||
|
||||
const variant =
|
||||
top?.variantId != null &&
|
||||
top.variantId !== '' &&
|
||||
exercise?.variants?.length
|
||||
? exercise.variants.find((v) => String(v.id) === String(top.variantId)) || null
|
||||
: null
|
||||
|
||||
const isCombination =
|
||||
exercise && String(exercise.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
|
||||
|
||||
const comboMethodProfileEffective = useMemo(() => {
|
||||
if (!exercise || !isCombination) return {}
|
||||
const fromPeek =
|
||||
top?.peekExtras?.catalog_method_profile &&
|
||||
typeof top.peekExtras.catalog_method_profile === 'object' &&
|
||||
!Array.isArray(top.peekExtras.catalog_method_profile) &&
|
||||
Object.keys(top.peekExtras.catalog_method_profile).length > 0
|
||||
? top.peekExtras.catalog_method_profile
|
||||
: exercise.method_profile || {}
|
||||
return effectiveComboMethodProfile(fromPeek, top?.peekExtras?.planning_method_profile ?? null)
|
||||
}, [exercise, isCombination, top?.peekExtras])
|
||||
|
||||
const planningAdjustedBadge =
|
||||
top?.peekExtras?.planning_method_profile != null &&
|
||||
typeof top.peekExtras.planning_method_profile === 'object' &&
|
||||
!Array.isArray(top.peekExtras.planning_method_profile)
|
||||
|
||||
const pushCandidatePeek = (id) => {
|
||||
const n = Number(id)
|
||||
if (!Number.isFinite(n)) return
|
||||
setStack((s) => [...s, { exerciseId: n, variantId: null, peekExtras: null }])
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
|
|
@ -107,9 +144,19 @@ export default function ExercisePeekModal({
|
|||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="admin-modal-sheet__header">
|
||||
<h3 id="exercise-peek-title" className="admin-modal-sheet__title">
|
||||
{loading ? '…' : exercise?.title || titleFallback || `Übung #${exerciseId}`}
|
||||
<div className="admin-modal-sheet__header" style={{ gap: '8px', flexWrap: 'wrap' }}>
|
||||
{stack.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ flexShrink: 0, fontWeight: 600 }}
|
||||
onClick={() => setStack((s) => (s.length > 1 ? s.slice(0, -1) : s))}
|
||||
>
|
||||
← Zurück
|
||||
</button>
|
||||
) : null}
|
||||
<h3 id="exercise-peek-title" className="admin-modal-sheet__title" style={{ flex: '1 1 200px', minWidth: 0 }}>
|
||||
{loading ? '…' : exercise?.title || titleFallback || (top?.exerciseId != null ? `Übung #${top.exerciseId}` : 'Übung')}
|
||||
</h3>
|
||||
<button type="button" className="btn btn-secondary admin-modal-sheet__close" onClick={onClose}>
|
||||
Schließen
|
||||
|
|
@ -130,14 +177,10 @@ export default function ExercisePeekModal({
|
|||
<CombinationPlanBracket
|
||||
methodArchetype={exercise.method_archetype || ''}
|
||||
methodProfile={comboMethodProfileEffective}
|
||||
combinationSlots={
|
||||
Array.isArray(exercise.combination_slots) ? exercise.combination_slots : []
|
||||
}
|
||||
planningAdjusted={
|
||||
peekExtras?.planning_method_profile != null &&
|
||||
typeof peekExtras.planning_method_profile === 'object' &&
|
||||
!Array.isArray(peekExtras.planning_method_profile)
|
||||
}
|
||||
combinationSlots={Array.isArray(exercise.combination_slots) ? exercise.combination_slots : []}
|
||||
planningAdjusted={planningAdjustedBadge}
|
||||
candidateInteraction="button"
|
||||
onCandidatePeek={pushCandidatePeek}
|
||||
/>
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '1rem 0' }} />
|
||||
</>
|
||||
|
|
@ -210,13 +253,17 @@ export default function ExercisePeekModal({
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
{exerciseId && (
|
||||
{top?.exerciseId != null ? (
|
||||
<div style={{ padding: '0 1rem 1rem', flexShrink: 0 }}>
|
||||
<Link to={`/exercises/${exerciseId}`} className="btn btn-secondary" style={{ width: '100%', textDecoration: 'none', textAlign: 'center', display: 'block' }}>
|
||||
<Link
|
||||
to={`/exercises/${top.exerciseId}`}
|
||||
className="btn btn-secondary"
|
||||
style={{ width: '100%', textDecoration: 'none', textAlign: 'center', display: 'block' }}
|
||||
>
|
||||
Vollständige Übungsseite öffnen
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1596,6 +1596,12 @@ export default function TrainingUnitSectionsEditor({
|
|||
typeof comboPlanningModalItem.planning_method_profile === 'object' &&
|
||||
!Array.isArray(comboPlanningModalItem.planning_method_profile)
|
||||
}
|
||||
candidateInteraction={onPeekExercise ? 'button' : 'none'}
|
||||
onCandidatePeek={
|
||||
onPeekExercise
|
||||
? (exId) => onPeekExercise(Number(exId), null, undefined)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
|
|||
import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaStrip'
|
||||
import CombinationPlanBracket from '../components/CombinationPlanBracket'
|
||||
import { formatSkillLevelSlug } from '../constants/skillLevels'
|
||||
import { sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
|
||||
|
||||
function TagRow({ exercise }) {
|
||||
const tags = []
|
||||
|
|
@ -52,28 +51,6 @@ function metaParts(exercise) {
|
|||
return parts
|
||||
}
|
||||
|
||||
/** Eindeutige Kandidaten-Übungen für Schnellnavigation unter der Klammerdarstellung */
|
||||
function flattenCombinationCandidateLinks(slots) {
|
||||
const rows = []
|
||||
const seen = new Set()
|
||||
sortCombinationSlotsForDisplay(slots || []).forEach((s) => {
|
||||
const cands =
|
||||
s.candidates && s.candidates.length
|
||||
? s.candidates
|
||||
: (s.candidate_exercise_ids || []).map((id) => ({
|
||||
exercise_id: id,
|
||||
title: null,
|
||||
}))
|
||||
cands.forEach((c) => {
|
||||
const eid = c.exercise_id
|
||||
if (eid == null || seen.has(eid)) return
|
||||
seen.add(eid)
|
||||
rows.push({ exercise_id: eid, title: (c.title || '').trim() || null })
|
||||
})
|
||||
})
|
||||
return rows
|
||||
}
|
||||
|
||||
function ExerciseDetailPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -135,9 +112,6 @@ function ExerciseDetailPage() {
|
|||
(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' &&
|
||||
Array.isArray(exercise.combination_slots) &&
|
||||
exercise.combination_slots.length > 0
|
||||
const combinationCandidateLinks = isCombinationDetail
|
||||
? flattenCombinationCandidateLinks(exercise.combination_slots)
|
||||
: []
|
||||
const catalogMethodProfileForBracket =
|
||||
exercise.method_profile &&
|
||||
typeof exercise.method_profile === 'object' &&
|
||||
|
|
@ -186,20 +160,9 @@ function ExerciseDetailPage() {
|
|||
methodProfile={catalogMethodProfileForBracket}
|
||||
combinationSlots={exercise.combination_slots}
|
||||
planningAdjusted={false}
|
||||
candidateInteraction="link"
|
||||
/>
|
||||
</div>
|
||||
{combinationCandidateLinks.length > 0 ? (
|
||||
<div style={{ marginTop: '14px', fontSize: '0.88rem' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: '6px', color: 'var(--text2)' }}>Verknüpfte Einzelübungen</div>
|
||||
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
|
||||
{combinationCandidateLinks.map((c) => (
|
||||
<li key={c.exercise_id}>
|
||||
<Link to={`/exercises/${c.exercise_id}`}>{c.title || `Übung #${c.exercise_id}`}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
|||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
import ExerciseFullContent from '../components/ExerciseFullContent'
|
||||
import ExercisePeekModal from '../components/ExercisePeekModal'
|
||||
import {
|
||||
flattenPlanTimeline,
|
||||
itemStableKey,
|
||||
|
|
@ -178,6 +179,7 @@ export default function TrainingCoachPage() {
|
|||
const [trainerAppend, setTrainerAppend] = useState('')
|
||||
const [saveMarkDone, setSaveMarkDone] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [candidatePeekId, setCandidatePeekId] = useState(null)
|
||||
const [saveOk, setSaveOk] = useState(null)
|
||||
|
||||
const reloadUnit = useCallback(async () => {
|
||||
|
|
@ -460,6 +462,12 @@ export default function TrainingCoachPage() {
|
|||
|
||||
return (
|
||||
<div className="training-coach-page training-coach-layout">
|
||||
<ExercisePeekModal
|
||||
key={candidatePeekId != null ? String(candidatePeekId) : 'coach-peek-closed'}
|
||||
open={candidatePeekId != null}
|
||||
exerciseId={candidatePeekId}
|
||||
onClose={() => setCandidatePeekId(null)}
|
||||
/>
|
||||
<nav
|
||||
className="no-print training-coach-meta-nav"
|
||||
style={{
|
||||
|
|
@ -750,6 +758,7 @@ export default function TrainingCoachPage() {
|
|||
? currentEntry?.item?.planning_method_profile ?? null
|
||||
: null
|
||||
}
|
||||
onCandidateExercisePeek={(id) => setCandidatePeekId(id)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1223,6 +1223,7 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
/>
|
||||
|
||||
<ExercisePeekModal
|
||||
key={peekCtx != null ? String(peekCtx.exerciseId) : 'fw-peek-closed'}
|
||||
open={peekCtx != null}
|
||||
exerciseId={peekCtx?.exerciseId || 0}
|
||||
variantId={peekCtx?.variantId ?? undefined}
|
||||
|
|
|
|||
|
|
@ -3087,6 +3087,7 @@ function TrainingPlanningPage() {
|
|||
}}
|
||||
/>
|
||||
<ExercisePeekModal
|
||||
key={planningPeekCtx != null ? String(planningPeekCtx.exerciseId) : 'plan-peek-closed'}
|
||||
open={planningPeekCtx != null}
|
||||
exerciseId={planningPeekCtx?.exerciseId}
|
||||
variantId={planningPeekCtx?.variantId ?? undefined}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user