shinkan-jinkendo/frontend/src/pages/ExerciseDetailPage.jsx
Lars 5cf775c920
All checks were successful
Deploy Development / deploy (push) Successful in 36s
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 23s
Test Suite / pytest-backend (pull_request) Successful in 23s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 7s
Test Suite / playwright-tests (pull_request) Successful in 23s
feat(exercises): bump version to 0.8.64 and enhance media handling
- Incremented application version to 0.8.64 and updated changelog with new features.
- Improved media handling in the Rich Text Editor with auto-scrolling during drag-and-drop.
- Added new CSS styles for video thumbnails and enhanced layout for media items.
- Removed deprecated `ExerciseAttachmentMediaStrip` from the ExerciseFullContent component.
- Updated ExerciseFormPage to manage form dirty state and prevent data loss on navigation.
2026-05-08 12:35:28 +02:00

264 lines
9.6 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, useLocation } from 'react-router-dom'
import api from '../utils/api'
import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaStrip'
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 location = useLocation()
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 fromExerciseEdit = location.state?.fromExerciseEdit === true
return (
<div className="exercise-detail-shell" style={{ padding: '12px 12px 24px' }}>
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}>
Übersicht
</button>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginLeft: 'auto' }}>
<Link to={`/exercises/${exercise.id}/edit`} className="btn btn-primary">
{fromExerciseEdit ? 'Zurück zur Bearbeitung' : 'Bearbeiten'}
</Link>
</div>
</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>
)}
<ExerciseAttachmentMediaStrip exercise={exercise} exerciseId={exercise.id} />
{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