shinkan-jinkendo/frontend/src/pages/ExerciseDetailPage.jsx
Lars 1ee1a2f2d9
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 2m0s
feat: update version to 0.7.9 and enhance exercise variant management
- Incremented application version to 0.7.9 and updated changelog to reflect new features.
- Added support for creating, updating, and deleting exercise variants via new API endpoints.
- Implemented functionality for reordering exercise variants, improving user experience in managing exercise options.
- Enhanced frontend components to display and manage exercise variants, including detailed information and editing capabilities.
2026-04-28 10:59:09 +02:00

322 lines
11 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 { sanitizeTrainerHtml } from '../utils/htmlUtils'
import { formatSkillLevelSlug } from '../constants/skillLevels'
const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
function resolveMediaUrl(filePath) {
if (!filePath) return null
if (filePath.startsWith('http://') || filePath.startsWith('https://')) return filePath
const p = filePath.startsWith('/') ? filePath : `/${filePath}`
return `${API_BASE}${p}`
}
function HtmlBlock({ html, className = '' }) {
if (!html || !String(html).trim()) return null
const safe = sanitizeTrainerHtml(html)
return (
<div
className={`rich-text-content ${className}`}
dangerouslySetInnerHTML={{ __html: safe }}
/>
)
}
function MediaBlock({ media }) {
if (media.embed_url) {
return (
<div style={{ marginTop: '0.5rem' }}>
<a href={media.embed_url} target="_blank" rel="noreferrer">
{media.embed_url}
</a>
{media.embed_platform && (
<span style={{ color: 'var(--text2)', marginLeft: '0.5rem', fontSize: '0.8rem' }}>
({media.embed_platform})
</span>
)}
</div>
)
}
const src = resolveMediaUrl(media.file_path)
if (!src) return null
if (media.media_type === 'image' || (media.mime_type && media.mime_type.startsWith('image/'))) {
return (
<img
src={src}
alt={media.title || media.original_filename || ''}
style={{ maxWidth: '100%', borderRadius: '8px', marginTop: '0.5rem' }}
/>
)
}
if (media.media_type === 'video' || (media.mime_type && media.mime_type.startsWith('video/'))) {
return <video src={src} controls style={{ width: '100%', marginTop: '0.5rem', borderRadius: '8px' }} />
}
return (
<a href={src} target="_blank" rel="noreferrer" style={{ display: 'inline-block', marginTop: '0.5rem' }}>
{media.title || media.original_filename || 'Datei öffnen'}
</a>
)
}
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', maxWidth: '640px', margin: '0 auto' }}>
<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)
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' }}>
<HtmlBlock html={exercise.summary} />
</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>
<HtmlBlock html={exercise.goal} />
</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>
<HtmlBlock html={exercise.preparation} />
</section>
)}
{exercise.execution && (
<section className="card exercise-detail-section">
<h2>Ablauf</h2>
<HtmlBlock html={exercise.execution} />
</section>
)}
{(exercise.media || []).length > 0 && (
<section className="card exercise-detail-section">
<h2>Medien</h2>
{exercise.media.map((m) => (
<div key={m.id} style={{ marginBottom: '1.25rem' }}>
<strong style={{ fontSize: '15px' }}>{m.title || m.original_filename || m.media_type}</strong>
{m.description && <p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{m.description}</p>}
<MediaBlock media={m} />
</div>
))}
</section>
)}
{exercise.trainer_notes && (
<section className="card exercise-detail-section">
<h2>Hinweise für Trainer</h2>
<HtmlBlock html={exercise.trainer_notes} />
</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' }}>
<HtmlBlock html={v.execution_changes} />
</div>
)}
</div>
)
})}
</section>
)}
</div>
)
}
export default ExerciseDetailPage