All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 23s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 24s
- Implemented media lifecycle management with new API endpoints for handling asset states (trash_soft, trash_hidden, recover, purge), improving media governance. - Updated frontend components to filter and display media based on lifecycle states, enhancing user experience and visibility. - Enhanced documentation in MEDIA_ASSETS_AND_ARCHIVE_SPEC.md to include guidelines for inline media references in exercise texts, establishing a clear implementation plan. - Incremented version to 0.8.42, reflecting the latest changes in media handling and lifecycle management.
323 lines
11 KiB
JavaScript
323 lines
11 KiB
JavaScript
import React, { useEffect, useState } from 'react'
|
||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||
import api from '../utils/api'
|
||
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
|
||
import { sanitizeTrainerHtml } from '../utils/htmlUtils'
|
||
import { formatSkillLevelSlug } from '../constants/skillLevels'
|
||
|
||
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, exerciseId }) {
|
||
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 = resolveExerciseMediaFileUrl(exerciseId, media)
|
||
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' }} 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' }}>
|
||
<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 & 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>
|
||
)}
|
||
|
||
{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>}
|
||
<MediaBlock media={m} exerciseId={exercise.id} />
|
||
</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
|