shinkan-jinkendo/frontend/src/components/ExerciseFullContent.jsx
Lars 8ac723eafe
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
feat: enhance media lifecycle management and inline media integration
- 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.
2026-05-07 12:55:50 +02:00

205 lines
8.0 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.

/**
* Voller Katalog-Inhalt einer Übung (Lesemodus für Coach/Mobile).
*/
import React from 'react'
import { Link } from 'react-router-dom'
import { sanitizeTrainerHtml } from '../utils/htmlUtils'
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
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
}
/**
* @param {{ exercise?: object|null, loading?: boolean, error?: string|null, exerciseId?: number }} props
*/
export default function ExerciseFullContent({ exercise, loading, error, exerciseId }) {
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '1rem' }}>
<div className="spinner" />
<p style={{ marginTop: '10px', color: 'var(--text2)', fontSize: '0.9rem' }}>Übung aus Katalog laden</p>
</div>
)
}
if (error) {
return <p style={{ color: 'var(--danger)', fontSize: '0.92rem', padding: '4px 0' }}>{error}</p>
}
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-coach-catalog" style={{ fontSize: '0.93rem', lineHeight: 1.5 }}>
<h2 style={{ margin: '0 0 8px', fontSize: '1.2rem', lineHeight: 1.35 }}>{exercise.title}</h2>
{meta.length > 0 && (
<p className="exercise-meta-line" style={{ marginBottom: '10px', color: 'var(--text3)', fontSize: '0.86rem' }}>
{meta.join(' · ')}
</p>
)}
<TagRow exercise={exercise} />
{exercise.summary && (
<section className="card" style={{ marginTop: '12px', padding: '12px 14px' }}>
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px', letterSpacing: '0.04em' }}>
Kurzbeschreibung
</h3>
<HtmlBlock html={exercise.summary} />
</section>
)}
{exercise.goal && (
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>Ziel</h3>
<HtmlBlock html={exercise.goal} />
</section>
)}
{(exercise.equipment || []).length > 0 && (
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>
Material &amp; Aufbau
</h3>
<ul style={{ paddingLeft: '1.1rem', margin: 0 }}>
{exercise.equipment.map((x, i) => (
<li key={i}>{x}</li>
))}
</ul>
</section>
)}
{exercise.preparation && (
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>Vorbereitung</h3>
<HtmlBlock html={exercise.preparation} />
</section>
)}
{exercise.execution && (
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>Ablauf</h3>
<HtmlBlock html={exercise.execution} />
</section>
)}
{visibleMedia.length > 0 && (
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>
Medien
</h3>
{visibleMedia.map((m) => (
<div key={m.id} style={{ marginBottom: '12px' }}>
<strong style={{ fontSize: '0.9rem' }}>{m.title || m.original_filename || m.media_type}</strong>
{String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_soft' && (
<p style={{ fontSize: '0.75rem', color: 'var(--danger)', margin: '4px 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.82rem', marginTop: '4px' }}>{m.description}</p>}
<MediaBlock media={m} exerciseId={exercise.id ?? exerciseId} />
</div>
))}
</section>
)}
{exercise.trainer_notes && (
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>
Hinweise Trainer (Katalog)
</h3>
<HtmlBlock html={exercise.trainer_notes} />
</section>
)}
{exerciseId != null && (
<p style={{ marginTop: '12px' }}>
<Link to={`/exercises/${exerciseId}`} style={{ fontSize: '0.86rem', color: 'var(--accent)' }}>
Volle Übungsseite im Browser
</Link>
</p>
)}
</div>
)
}