shinkan-jinkendo/frontend/src/components/ExercisePeekModal.jsx
Lars a8942a9e4e
Some checks failed
Test Suite / lint-backend (push) Waiting to run
Test Suite / build-frontend (push) Waiting to run
Test Suite / playwright-tests (push) Waiting to run
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Has been cancelled
feat(version): bump to 0.8.110 and enhance combination exercise features
- Updated app version to 0.8.110, reflecting recent improvements in combination exercise handling.
- Introduced `load_combination_slots_for_exercise` function to streamline fetching combination slots for exercises.
- Enhanced `TrainingPlanningPage` and `ExercisePeekModal` to utilize the new combination slots functionality, improving user experience.
- Updated changelog to document the latest changes and feature enhancements.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 14:24:55 +02:00

224 lines
8.6 KiB
JavaScript

/**
* Schnellansicht einer Übung aus dem Katalog (ohne die Planungsseite zu verlassen).
*/
import React, { useEffect, useMemo, 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>
)
}
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)
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])
useEffect(() => {
if (!open) {
setExercise(null)
setErr(null)
return
}
if (!exerciseId) {
setErr('Keine Übung gewählt')
return
}
let cancelled = false
;(async () => {
setLoading(true)
setErr(null)
try {
const data = await api.getExercise(exerciseId)
if (!cancelled) setExercise(data)
} catch (e) {
if (!cancelled) setErr(e.message || 'Laden fehlgeschlagen')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [open, exerciseId, variantId])
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">
<h3 id="exercise-peek-title" className="admin-modal-sheet__title">
{loading ? '…' : exercise?.title || titleFallback || `Übung #${exerciseId}`}
</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={
peekExtras?.planning_method_profile != null &&
typeof peekExtras.planning_method_profile === 'object' &&
!Array.isArray(peekExtras.planning_method_profile)
}
/>
<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>
{exerciseId && (
<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' }}>
Vollständige Übungsseite öffnen
</Link>
</div>
)}
</div>
</div>
)
}