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>
271 lines
10 KiB
JavaScript
271 lines
10 KiB
JavaScript
/**
|
|
* 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, useRef, useState } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import api from '../utils/api'
|
|
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
|
|
import CombinationPlanBracket from './CombinationPlanBracket'
|
|
import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile'
|
|
|
|
function TagMini({ exercise }) {
|
|
const parts = []
|
|
;(exercise.focus_areas || []).slice(0, 5).forEach((f) => {
|
|
parts.push(f.name)
|
|
})
|
|
if (parts.length === 0) return null
|
|
return (
|
|
<div className="exercise-tag-row" style={{ marginTop: '10px' }}>
|
|
{parts.map((p, i) => (
|
|
<span key={i} className="exercise-tag exercise-tag--accent">
|
|
{p}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** @typedef {{ exerciseId: number, variantId?: number | null, peekExtras?: object | null }} PeekStackEntry */
|
|
|
|
export default function ExercisePeekModal({
|
|
open,
|
|
exerciseId,
|
|
variantId,
|
|
onClose,
|
|
titleFallback,
|
|
/** Nur Planung: effektives method_profile aus Zeilen-Katalog + Planungs-Override */
|
|
peekExtras,
|
|
}) {
|
|
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([])
|
|
|
|
/** @type {React.MutableRefObject<boolean>} */
|
|
const wasOpenRef = useRef(false)
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setStack([])
|
|
wasOpenRef.current = false
|
|
return
|
|
}
|
|
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
|
|
;(async () => {
|
|
setLoading(true)
|
|
setErr(null)
|
|
try {
|
|
const data = await api.getExercise(top.exerciseId)
|
|
if (!cancelled) setExercise(data)
|
|
} catch (e) {
|
|
if (!cancelled) setErr(e.message || 'Laden fehlgeschlagen')
|
|
} finally {
|
|
if (!cancelled) setLoading(false)
|
|
}
|
|
})()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [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
|
|
|
|
const sheetWide = Boolean(isCombination && exercise && !loading)
|
|
|
|
return (
|
|
<div className="admin-modal-backdrop" role="presentation" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
|
<div
|
|
className="admin-modal-sheet"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="exercise-peek-title"
|
|
style={{
|
|
maxWidth: sheetWide ? 'min(840px, 96vw)' : '620px',
|
|
width: '100%',
|
|
maxHeight: '88vh',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<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
|
|
</button>
|
|
</div>
|
|
<div style={{ overflowY: 'auto', padding: '1rem', flex: 1 }}>
|
|
{loading && (
|
|
<div style={{ textAlign: 'center', color: 'var(--text2)' }}>
|
|
<div className="spinner" />
|
|
<p style={{ marginTop: '0.65rem' }}>Laden…</p>
|
|
</div>
|
|
)}
|
|
{!loading && err && <p style={{ color: 'var(--danger)' }}>{err}</p>}
|
|
{!loading && exercise && (
|
|
<>
|
|
{isCombination ? (
|
|
<>
|
|
<CombinationPlanBracket
|
|
methodArchetype={exercise.method_archetype || ''}
|
|
methodProfile={comboMethodProfileEffective}
|
|
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' }} />
|
|
</>
|
|
) : null}
|
|
|
|
{variant ? (
|
|
<div
|
|
style={{
|
|
marginBottom: '0.75rem',
|
|
padding: '8px 10px',
|
|
borderRadius: '8px',
|
|
background: 'var(--surface2)',
|
|
border: '1px solid var(--border)',
|
|
}}
|
|
>
|
|
<div style={{ fontSize: '0.78rem', fontWeight: 700, color: 'var(--text3)', marginBottom: 4 }}>
|
|
Variante
|
|
</div>
|
|
<div style={{ fontWeight: 700, fontSize: '0.95rem' }}>
|
|
{variant.variant_name || `Variante #${variant.id}`}
|
|
</div>
|
|
{variant.description ? (
|
|
<div style={{ marginTop: 8, fontSize: '0.9rem', color: 'var(--text2)' }}>
|
|
<ExerciseRichTextBlock html={variant.description} exerciseId={exercise.id} media={exercise.media} />
|
|
</div>
|
|
) : null}
|
|
{variant.execution_changes ? (
|
|
<div style={{ marginTop: 10 }}>
|
|
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', marginBottom: 6 }}>
|
|
Durchführung (Variante)
|
|
</h4>
|
|
<ExerciseRichTextBlock html={variant.execution_changes} exerciseId={exercise.id} media={exercise.media} />
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
{exercise.summary && (
|
|
<div style={{ fontSize: '0.95rem', color: 'var(--text2)' }}>
|
|
<ExerciseRichTextBlock html={exercise.summary} exerciseId={exercise.id} media={exercise.media} />
|
|
</div>
|
|
)}
|
|
<TagMini exercise={exercise} />
|
|
{(exercise.goal || exercise.preparation || exercise.execution || exercise.trainer_notes) && (
|
|
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '1rem 0' }} />
|
|
)}
|
|
{exercise.goal && (
|
|
<>
|
|
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', marginBottom: 6 }}>Ziel</h4>
|
|
<ExerciseRichTextBlock html={exercise.goal} exerciseId={exercise.id} media={exercise.media} />
|
|
</>
|
|
)}
|
|
{exercise.preparation && (
|
|
<>
|
|
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', margin: '14px 0 6px' }}>Vorbereitung</h4>
|
|
<ExerciseRichTextBlock html={exercise.preparation} exerciseId={exercise.id} media={exercise.media} />
|
|
</>
|
|
)}
|
|
{exercise.execution && (
|
|
<>
|
|
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', margin: '14px 0 6px' }}>Ablauf</h4>
|
|
<ExerciseRichTextBlock html={exercise.execution} exerciseId={exercise.id} media={exercise.media} />
|
|
</>
|
|
)}
|
|
{exercise.trainer_notes && (
|
|
<>
|
|
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', margin: '14px 0 6px' }}>Trainer-Hinweise</h4>
|
|
<ExerciseRichTextBlock html={exercise.trainer_notes} exerciseId={exercise.id} media={exercise.media} />
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
{top?.exerciseId != null ? (
|
|
<div style={{ padding: '0 1rem 1rem', flexShrink: 0 }}>
|
|
<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>
|
|
)
|
|
}
|