shinkan-jinkendo/frontend/src/pages/ExerciseDetailPage.jsx
Lars cc51b0f08f
All checks were successful
Deploy Development / deploy (push) Successful in 37s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 24s
feat(exercises): implement inline media support in exercise content
- Added functionality for inline media references in exercise text using `{{exerciseMedia:id}}` syntax, which normalizes to a canonical `<span>` element.
- Updated the frontend to utilize `ExerciseRichTextBlock` for rendering exercise content, allowing for embedded media display.
- Enhanced the Rich Text Editor to support inserting inline media placeholders.
- Version bump to 0.8.60 to reflect these changes in media handling and exercise content management.
2026-05-08 11:44:29 +02:00

280 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../utils/api'
import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
import ExerciseMediaEmbed from '../components/ExerciseMediaEmbed'
import { formatSkillLevelSlug } from '../constants/skillLevels'
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
}
function ExerciseDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
const [exercise, setExercise] = useState(null)
const [error, setError] = useState(null)
const [loading, setLoading] = useState(true)
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 (
<div style={{ padding: '1rem' }} className="app-page">
<div className="card">
<h2>Übung</h2>
<p style={{ color: 'var(--danger)' }}>{msg}</p>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}>
Zur Übersicht
</button>
</div>
</div>
)
}
if (!exercise) return null
const meta = metaParts(exercise)
const visibleMedia = (exercise.media || []).filter((m) => {
const lc = String(m.asset_lifecycle_state || 'active').toLowerCase()
return lc !== 'trash_hidden'
})
return (
<div className="exercise-detail-shell" style={{ padding: '12px 12px 24px' }}>
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap' }}>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}>
Übersicht
</button>
<Link to={`/exercises/${exercise.id}/edit`} className="btn btn-primary">
Bearbeiten
</Link>
</div>
<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' }}>
<ExerciseRichTextBlock html={exercise.summary} exerciseId={exercise.id} media={exercise.media} />
</div>
)}
<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>}
</div>
{meta.length > 0 && <p className="exercise-meta-line">{meta.join(' · ')}</p>}
</div>
{exercise.goal && (
<section className="card exercise-detail-section">
<h2>Ziel</h2>
<ExerciseRichTextBlock html={exercise.goal} exerciseId={exercise.id} media={exercise.media} />
</section>
)}
{(exercise.equipment || []).length > 0 && (
<section className="card exercise-detail-section">
<h2>Material &amp; Aufbau</h2>
<ul style={{ paddingLeft: '1.2rem', margin: 0 }}>
{exercise.equipment.map((x, i) => (
<li key={i}>{x}</li>
))}
</ul>
</section>
)}
{exercise.preparation && (
<section className="card exercise-detail-section">
<h2>Vorbereitung</h2>
<ExerciseRichTextBlock html={exercise.preparation} exerciseId={exercise.id} media={exercise.media} />
</section>
)}
{exercise.execution && (
<section className="card exercise-detail-section">
<h2>Ablauf</h2>
<ExerciseRichTextBlock html={exercise.execution} exerciseId={exercise.id} media={exercise.media} />
</section>
)}
{visibleMedia.length > 0 && (
<section className="card exercise-detail-section">
<h2>Medien</h2>
{visibleMedia.map((m) => (
<div key={m.id} style={{ marginBottom: '1.25rem' }}>
<strong style={{ fontSize: '15px' }}>{m.title || m.original_filename || m.media_type}</strong>
{String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_soft' && (
<p style={{ fontSize: '12px', color: 'var(--danger)', margin: '6px 0 0' }}>
Hinweis: Dieses Medium ist im Papierkorb und steht künftig nicht mehr zur Verfügung.
</p>
)}
{m.description && <p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{m.description}</p>}
<ExerciseMediaEmbed media={m} exerciseId={exercise.id} />
</div>
))}
</section>
)}
{exercise.trainer_notes && (
<section className="card exercise-detail-section">
<h2>Hinweise für Trainer</h2>
<ExerciseRichTextBlock html={exercise.trainer_notes} exerciseId={exercise.id} media={exercise.media} />
</section>
)}
{(exercise.skills || []).length > 0 && (
<section className="card exercise-detail-section">
<h2>Fähigkeiten</h2>
<div className="exercise-tag-row">
{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>
)
})}
</div>
</section>
)}
{(exercise.variants || []).length > 0 && (
<section className="card exercise-detail-section">
<h2>Varianten</h2>
{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' }}>
<ExerciseRichTextBlock
html={v.execution_changes}
exerciseId={exercise.id}
media={exercise.media}
/>
</div>
)}
</div>
)
})}
</section>
)}
</div>
)
}
export default ExerciseDetailPage