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
- 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.
1894 lines
69 KiB
JavaScript
1894 lines
69 KiB
JavaScript
import React, { useEffect, useState, useRef, useMemo } from 'react'
|
||
import { useNavigate, useParams, Link } from 'react-router-dom'
|
||
import api, { buildExerciseApiPayload } from '../utils/api'
|
||
import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
|
||
import RichTextEditor from '../components/RichTextEditor'
|
||
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
|
||
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
|
||
import { useAuth } from '../context/AuthContext'
|
||
|
||
/** MIME/Dateiname → Übungs-media_type; null → Dropdown-Fallback. */
|
||
function inferExerciseMediaType(file) {
|
||
if (!file) return null
|
||
const mime = (file.type || '').toLowerCase()
|
||
if (mime.startsWith('image/')) return 'image'
|
||
if (mime.startsWith('video/')) return 'video'
|
||
if (mime === 'application/pdf' || mime.includes('pdf')) return 'document'
|
||
const name = (file.name || '').toLowerCase()
|
||
if (/\.(mp4|webm|mov|mkv|avi|m4v|mpeg|mpg)$/.test(name)) return 'video'
|
||
if (/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/.test(name)) return 'image'
|
||
if (/\.pdf$/.test(name)) return 'document'
|
||
return null
|
||
}
|
||
|
||
/** Kachelvorschau: Video nutzt ersten Frame (metadata), Bild = img. */
|
||
function ExerciseMediaThumbTile({ exerciseId, media, onOpenPreview }) {
|
||
const src = !media.embed_url ? resolveExerciseMediaFileUrl(exerciseId, media) : null
|
||
const commonStyle = {
|
||
width: '100%',
|
||
height: '100%',
|
||
objectFit: 'cover',
|
||
}
|
||
return (
|
||
<div
|
||
role="button"
|
||
tabIndex={0}
|
||
title="Vorschau"
|
||
onClick={() => onOpenPreview(media)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault()
|
||
onOpenPreview(media)
|
||
}
|
||
}}
|
||
style={{
|
||
width: 72,
|
||
height: 72,
|
||
flexShrink: 0,
|
||
borderRadius: '8px',
|
||
overflow: 'hidden',
|
||
background: 'var(--surface2, rgba(127,127,127,0.12))',
|
||
border: '1px solid var(--border)',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
{media.embed_url ? (
|
||
<span style={{ fontSize: '11px', padding: '4px', color: 'var(--text2)', textAlign: 'center' }}>
|
||
{media.embed_platform || 'Embed'}
|
||
</span>
|
||
) : (media.mime_type?.startsWith('image/') || media.media_type === 'image') && src ? (
|
||
<img alt="" src={src} style={commonStyle} />
|
||
) : (media.mime_type?.startsWith('video/') || media.media_type === 'video') && src ? (
|
||
<video
|
||
src={src}
|
||
muted
|
||
playsInline
|
||
preload="metadata"
|
||
style={{ ...commonStyle, pointerEvents: 'none' }}
|
||
onLoadedMetadata={(e) => {
|
||
try {
|
||
const el = e.currentTarget
|
||
const d = el.duration
|
||
el.currentTime = Number.isFinite(d) && d > 0 ? Math.min(0.05, d * 0.01) : 0.05
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
}}
|
||
/>
|
||
) : (
|
||
<span style={{ fontSize: '11px', color: 'var(--text2)' }}>Datei</span>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const INTENSITY_OPTIONS = [
|
||
{ value: '', label: '—' },
|
||
{ value: 'niedrig', label: 'niedrig' },
|
||
{ value: 'mittel', label: 'mittel' },
|
||
{ value: 'hoch', label: 'hoch' },
|
||
]
|
||
|
||
const VARIANT_DIFFICULTY = [
|
||
{ value: '', label: '—' },
|
||
{ value: 'easier', label: 'Einfacher' },
|
||
{ value: 'same', label: 'Gleich' },
|
||
{ value: 'harder', label: 'Schwerer' },
|
||
{ value: 'adapted', label: 'Angepasst' },
|
||
]
|
||
|
||
function emptyVariantDraft() {
|
||
return {
|
||
variant_name: '',
|
||
description: '',
|
||
execution_changes: '',
|
||
duration_min: '',
|
||
duration_max: '',
|
||
equipment_lines: '',
|
||
difficulty_adjustment: '',
|
||
progression_level: 1,
|
||
prerequisite_variant_id: '',
|
||
}
|
||
}
|
||
|
||
function apiVariantToRow(v) {
|
||
let lines = ''
|
||
const eq = v.equipment_changes
|
||
if (Array.isArray(eq)) {
|
||
lines = eq.join('\n')
|
||
} else if (typeof eq === 'string' && eq.trim()) {
|
||
try {
|
||
const p = JSON.parse(eq)
|
||
lines = Array.isArray(p) ? p.join('\n') : eq
|
||
} catch {
|
||
lines = eq
|
||
}
|
||
}
|
||
return {
|
||
...v,
|
||
duration_min: v.duration_min ?? '',
|
||
duration_max: v.duration_max ?? '',
|
||
equipment_lines: lines,
|
||
progression_level: v.progression_level ?? 1,
|
||
prerequisite_variant_id: v.prerequisite_variant_id ?? '',
|
||
difficulty_adjustment: v.difficulty_adjustment ?? '',
|
||
}
|
||
}
|
||
|
||
function buildVariantPayloadFromRow(row) {
|
||
const lines = (row.equipment_lines || '')
|
||
.split(/[\n,]+/)
|
||
.map((s) => s.trim())
|
||
.filter(Boolean)
|
||
const pl =
|
||
row.progression_level === '' || row.progression_level == null
|
||
? 1
|
||
: parseInt(row.progression_level, 10)
|
||
const so =
|
||
row.sequence_order === '' || row.sequence_order == null
|
||
? null
|
||
: parseInt(row.sequence_order, 10)
|
||
return {
|
||
variant_name: (row.variant_name || '').trim(),
|
||
description: (row.description || '').trim() || null,
|
||
execution_changes: (row.execution_changes || '').trim() || null,
|
||
duration_min: row.duration_min === '' || row.duration_min == null ? null : parseInt(row.duration_min, 10),
|
||
duration_max: row.duration_max === '' || row.duration_max == null ? null : parseInt(row.duration_max, 10),
|
||
equipment_changes: lines,
|
||
difficulty_adjustment: row.difficulty_adjustment || null,
|
||
progression_level: Number.isNaN(pl) ? 1 : pl,
|
||
sequence_order: so !== null && Number.isNaN(so) ? null : so,
|
||
prerequisite_variant_id:
|
||
row.prerequisite_variant_id === '' || row.prerequisite_variant_id == null
|
||
? null
|
||
: parseInt(row.prerequisite_variant_id, 10),
|
||
}
|
||
}
|
||
|
||
/** Gemeinsame Felder für „Variante bearbeiten“ und „Neue Variante“. */
|
||
function ExerciseVariantFields({ row, onPatch, prerequisiteOthers, rteMinHeight = '110px', exerciseMediaInsertSlots }) {
|
||
return (
|
||
<>
|
||
<div className="form-row">
|
||
<label className="form-label">Variantenname *</label>
|
||
<input
|
||
type="text"
|
||
className="form-input"
|
||
value={row.variant_name || ''}
|
||
onChange={(e) => onPatch({ variant_name: e.target.value })}
|
||
minLength={3}
|
||
/>
|
||
</div>
|
||
<div className="form-row">
|
||
<label className="form-label">Kurzbeschreibung</label>
|
||
<textarea
|
||
className="form-input"
|
||
rows={2}
|
||
value={row.description || ''}
|
||
onChange={(e) => onPatch({ description: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="form-row">
|
||
<label className="form-label">Abweichungen zur Durchführung</label>
|
||
<RichTextEditor
|
||
value={row.execution_changes || ''}
|
||
onChange={(html) => onPatch({ execution_changes: html })}
|
||
placeholder="Was unterscheidet diese Variante? (Listen über Symbolleiste)"
|
||
minHeight={rteMinHeight}
|
||
insertExerciseMediaSlots={exerciseMediaInsertSlots}
|
||
/>
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||
<div className="form-row">
|
||
<label className="form-label">Dauer Min</label>
|
||
<input
|
||
type="number"
|
||
className="form-input"
|
||
value={row.duration_min}
|
||
onChange={(e) => onPatch({ duration_min: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="form-row">
|
||
<label className="form-label">Dauer Max</label>
|
||
<input
|
||
type="number"
|
||
className="form-input"
|
||
value={row.duration_max}
|
||
onChange={(e) => onPatch({ duration_max: e.target.value })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="form-row">
|
||
<label className="form-label">Materialänderungen (eine Zeile pro Eintrag)</label>
|
||
<textarea
|
||
className="form-input"
|
||
rows={2}
|
||
value={row.equipment_lines || ''}
|
||
onChange={(e) => onPatch({ equipment_lines: e.target.value })}
|
||
placeholder="+ Pratzen"
|
||
/>
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: '10px' }}>
|
||
<div className="form-row">
|
||
<label className="form-label">Schwere relativ</label>
|
||
<select
|
||
className="form-input"
|
||
value={row.difficulty_adjustment || ''}
|
||
onChange={(e) => onPatch({ difficulty_adjustment: e.target.value })}
|
||
>
|
||
{VARIANT_DIFFICULTY.map((o) => (
|
||
<option key={o.label} value={o.value}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="form-row">
|
||
<label className="form-label">Progressions-Stufe (1–10)</label>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={10}
|
||
className="form-input"
|
||
value={row.progression_level}
|
||
onChange={(e) =>
|
||
onPatch({
|
||
progression_level: e.target.value === '' ? '' : parseInt(e.target.value, 10),
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="form-row">
|
||
<label className="form-label">Voraussetzungs-Variante</label>
|
||
<select
|
||
className="form-input"
|
||
value={
|
||
row.prerequisite_variant_id === '' || row.prerequisite_variant_id == null
|
||
? ''
|
||
: String(row.prerequisite_variant_id)
|
||
}
|
||
onChange={(e) =>
|
||
onPatch({
|
||
prerequisite_variant_id: e.target.value === '' ? '' : parseInt(e.target.value, 10),
|
||
})
|
||
}
|
||
>
|
||
<option value="">— keine —</option>
|
||
{prerequisiteOthers.map((o) => (
|
||
<option key={o.id} value={o.id}>
|
||
{o.variant_name || `Variante #${o.id}`}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
function emptyForm() {
|
||
return {
|
||
title: '',
|
||
summary: '',
|
||
goal: '',
|
||
execution: '',
|
||
preparation: '',
|
||
trainer_notes: '',
|
||
equipmentLines: '',
|
||
duration_min: '',
|
||
duration_max: '',
|
||
group_size_min: '',
|
||
group_size_max: '',
|
||
focus_areas_multi: [],
|
||
training_styles_multi: [],
|
||
training_types_multi: [],
|
||
target_groups_multi: [],
|
||
visibility: 'private',
|
||
status: 'draft',
|
||
skills: [],
|
||
}
|
||
}
|
||
|
||
function detailToForm(exercise) {
|
||
return {
|
||
title: exercise.title || '',
|
||
summary: exercise.summary || '',
|
||
goal: exercise.goal || '',
|
||
execution: exercise.execution || '',
|
||
preparation: exercise.preparation || '',
|
||
trainer_notes: exercise.trainer_notes || '',
|
||
equipmentLines: (exercise.equipment || []).join('\n'),
|
||
duration_min: exercise.duration_min ?? '',
|
||
duration_max: exercise.duration_max ?? '',
|
||
group_size_min: exercise.group_size_min ?? '',
|
||
group_size_max: exercise.group_size_max ?? '',
|
||
focus_areas_multi: (exercise.focus_areas || []).map((f) => ({
|
||
focus_area_id: f.focus_area_id,
|
||
is_primary: !!f.is_primary,
|
||
})),
|
||
training_styles_multi: (exercise.training_styles || []).map((t) => ({
|
||
training_style_id: t.training_style_id,
|
||
is_primary: !!t.is_primary,
|
||
})),
|
||
training_types_multi: (exercise.training_types || []).map((t) => ({
|
||
training_type_id: t.training_type_id,
|
||
is_primary: !!t.is_primary,
|
||
})),
|
||
target_groups_multi: (exercise.target_groups || []).map((g) => ({
|
||
target_group_id: g.target_group_id,
|
||
is_primary: !!g.is_primary,
|
||
})),
|
||
visibility: exercise.visibility || 'private',
|
||
status: exercise.status || 'draft',
|
||
skills:
|
||
exercise.skills?.map((s) => ({
|
||
skill_id: s.skill_id,
|
||
is_primary: s.is_primary || false,
|
||
intensity: s.intensity || '',
|
||
required_level: normalizeSkillLevelSlug(s.required_level),
|
||
target_level: normalizeSkillLevelSlug(s.target_level),
|
||
})) || [],
|
||
}
|
||
}
|
||
|
||
function MultiAssocBlock({ title, rows, setRows, options, idKey, emptyLabel }) {
|
||
const setPrimary = (idx) => {
|
||
setRows(rows.map((r, i) => ({ ...r, is_primary: i === idx })))
|
||
}
|
||
const updateRow = (idx, patch) => {
|
||
const next = rows.map((r, i) => (i === idx ? { ...r, ...patch } : r))
|
||
if (patch.is_primary === true) {
|
||
next.forEach((r, i) => {
|
||
if (i !== idx) r.is_primary = false
|
||
})
|
||
}
|
||
setRows(next)
|
||
}
|
||
const addRow = () => setRows([...rows, { [idKey]: '', is_primary: rows.length === 0 }])
|
||
const removeRow = (idx) => {
|
||
const next = rows.filter((_, i) => i !== idx)
|
||
if (next.length && !next.some((r) => r.is_primary)) next[0].is_primary = true
|
||
setRows(next)
|
||
}
|
||
|
||
return (
|
||
<div className="multi-assoc-block">
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' }}>
|
||
<h3>{title}</h3>
|
||
<button type="button" className="btn btn-secondary" style={{ fontSize: '12px', padding: '4px 10px' }} onClick={addRow}>
|
||
+ Eintrag
|
||
</button>
|
||
</div>
|
||
{rows.length === 0 && (
|
||
<p style={{ fontSize: '13px', color: 'var(--text2)', margin: 0 }}>{emptyLabel}</p>
|
||
)}
|
||
{rows.map((row, idx) => (
|
||
<div key={idx} className="multi-assoc-row">
|
||
<select
|
||
className="form-input"
|
||
value={row[idKey] || ''}
|
||
onChange={(e) => updateRow(idx, { [idKey]: e.target.value ? parseInt(e.target.value, 10) : '' })}
|
||
>
|
||
<option value="">— wählen —</option>
|
||
{options.map((o) => (
|
||
<option key={o.id} value={o.id}>
|
||
{o.icon ? `${o.icon} ` : ''}
|
||
{o.name}
|
||
{o.abbreviation ? ` (${o.abbreviation})` : ''}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '13px', whiteSpace: 'nowrap' }}>
|
||
<input
|
||
type="radio"
|
||
name={`primary-${idKey}`}
|
||
checked={!!row.is_primary}
|
||
onChange={() => setPrimary(idx)}
|
||
/>
|
||
primär
|
||
</label>
|
||
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => removeRow(idx)}>
|
||
✕
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ExerciseFormPage() {
|
||
const { id: routeId } = useParams()
|
||
const navigate = useNavigate()
|
||
const { user } = useAuth()
|
||
const isSuperadmin = user?.role === 'superadmin'
|
||
const exerciseId = routeId && !Number.isNaN(parseInt(routeId, 10)) ? parseInt(routeId, 10) : null
|
||
const isEdit = exerciseId != null
|
||
|
||
const [formData, setFormData] = useState(emptyForm)
|
||
const [skillsCatalog, setSkillsCatalog] = useState([])
|
||
const [focusAreas, setFocusAreas] = useState([])
|
||
const [styleDirections, setStyleDirections] = useState([])
|
||
const [trainingTypes, setTrainingTypes] = useState([])
|
||
const [targetGroups, setTargetGroups] = useState([])
|
||
const [mediaList, setMediaList] = useState([])
|
||
const [loading, setLoading] = useState(!!isEdit)
|
||
const [saving, setSaving] = useState(false)
|
||
const [skillPick, setSkillPick] = useState('')
|
||
const [variants, setVariants] = useState([])
|
||
const [variantDraft, setVariantDraft] = useState(() => emptyVariantDraft())
|
||
const [variantSavingId, setVariantSavingId] = useState(null)
|
||
const [variantBusy, setVariantBusy] = useState(false)
|
||
const [variantEditSelection, setVariantEditSelection] = useState(null)
|
||
const variantsDetailsRef = useRef(null)
|
||
|
||
const [mediaFiles, setMediaFiles] = useState([])
|
||
const [mediaType, setMediaType] = useState('image')
|
||
const [mediaTitle, setMediaTitle] = useState('')
|
||
const [mediaContext, setMediaContext] = useState('ablauf')
|
||
const [embedUrl, setEmbedUrl] = useState('')
|
||
const [embedTitle, setEmbedTitle] = useState('')
|
||
const [mediaFields, setMediaFields] = useState({})
|
||
const [mediaSavingId, setMediaSavingId] = useState(null)
|
||
const [archiveOpen, setArchiveOpen] = useState(false)
|
||
const [archiveQ, setArchiveQ] = useState('')
|
||
const [archiveCtx, setArchiveCtx] = useState('ablauf')
|
||
const [archiveLoading, setArchiveLoading] = useState(false)
|
||
const [archiveItems, setArchiveItems] = useState([])
|
||
const [archiveError, setArchiveError] = useState(null)
|
||
const [mediaPreview, setMediaPreview] = useState(null)
|
||
|
||
const exerciseMediaInsertSlots = useMemo(() => {
|
||
if (!isEdit) return []
|
||
return (mediaList || [])
|
||
.filter((m) => m?.id != null)
|
||
.map((m) => ({
|
||
id: m.id,
|
||
label: (m.title && String(m.title).trim()) || m.original_filename || `Medium #${m.id}`,
|
||
}))
|
||
}, [isEdit, mediaList])
|
||
|
||
useEffect(() => {
|
||
const next = {}
|
||
for (const m of mediaList) {
|
||
next[m.id] = {
|
||
title: m.title || '',
|
||
context: m.context || 'ablauf',
|
||
}
|
||
}
|
||
setMediaFields(next)
|
||
}, [mediaList])
|
||
|
||
useEffect(() => {
|
||
if (!archiveOpen) return undefined
|
||
let cancelled = false
|
||
const t = setTimeout(async () => {
|
||
setArchiveLoading(true)
|
||
setArchiveError(null)
|
||
try {
|
||
const res = await api.listMediaAssets({
|
||
q: archiveQ.trim() || undefined,
|
||
limit: 40,
|
||
})
|
||
if (!cancelled) setArchiveItems(res.items || [])
|
||
} catch (e) {
|
||
if (!cancelled) setArchiveError(e.message || String(e))
|
||
} finally {
|
||
if (!cancelled) setArchiveLoading(false)
|
||
}
|
||
}, 280)
|
||
return () => {
|
||
cancelled = true
|
||
clearTimeout(t)
|
||
}
|
||
}, [archiveOpen, archiveQ])
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
const boot = async () => {
|
||
try {
|
||
const [skillsData, faData, sdData, ttData, tgData] = await Promise.all([
|
||
api.listSkills(),
|
||
api.listFocusAreas(),
|
||
api.listTrainingStyles(),
|
||
api.listTrainingTypes(),
|
||
api.listTargetGroups(),
|
||
])
|
||
if (cancelled) return
|
||
setSkillsCatalog(skillsData)
|
||
setFocusAreas(faData)
|
||
setStyleDirections(sdData)
|
||
setTrainingTypes(ttData)
|
||
setTargetGroups(tgData)
|
||
} catch (e) {
|
||
if (!cancelled) {
|
||
console.error(e)
|
||
alert(
|
||
'Kataloge (Fokus, Stile, Zielgruppen, Fähigkeiten) konnten nicht geladen werden: ' +
|
||
(e.message || e),
|
||
)
|
||
}
|
||
}
|
||
}
|
||
boot()
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (!isEdit) {
|
||
setFormData(emptyForm())
|
||
setMediaList([])
|
||
setVariants([])
|
||
setVariantDraft(emptyVariantDraft())
|
||
setVariantEditSelection(null)
|
||
setLoading(false)
|
||
return
|
||
}
|
||
let cancelled = false
|
||
const load = async () => {
|
||
setLoading(true)
|
||
try {
|
||
const exercise = await api.getExercise(exerciseId)
|
||
if (cancelled) return
|
||
setFormData(detailToForm(exercise))
|
||
setMediaList(exercise.media || [])
|
||
setVariants((exercise.variants || []).map(apiVariantToRow))
|
||
setVariantDraft(emptyVariantDraft())
|
||
setVariantEditSelection(null)
|
||
} catch (err) {
|
||
if (!cancelled) {
|
||
alert(err.message || 'Übung nicht ladbar')
|
||
navigate('/exercises')
|
||
}
|
||
} finally {
|
||
if (!cancelled) setLoading(false)
|
||
}
|
||
}
|
||
load()
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [isEdit, exerciseId, navigate])
|
||
|
||
useEffect(() => {
|
||
if (variantEditSelection == null || variantEditSelection === 'new') return
|
||
if (!variants.some((v) => v.id === variantEditSelection)) {
|
||
setVariantEditSelection(null)
|
||
}
|
||
}, [variants, variantEditSelection])
|
||
|
||
useEffect(() => {
|
||
if (variantEditSelection != null && variantsDetailsRef.current) {
|
||
variantsDetailsRef.current.open = true
|
||
}
|
||
}, [variantEditSelection])
|
||
|
||
const updateFormField = (field, value) => {
|
||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||
}
|
||
|
||
const addSkillRow = () => {
|
||
const id = skillPick ? parseInt(skillPick, 10) : null
|
||
if (!id) {
|
||
alert('Fähigkeit wählen')
|
||
return
|
||
}
|
||
if (formData.skills.some((s) => s.skill_id === id)) {
|
||
alert('Bereits zugeordnet')
|
||
return
|
||
}
|
||
updateFormField('skills', [
|
||
...formData.skills,
|
||
{
|
||
skill_id: id,
|
||
is_primary: formData.skills.length === 0,
|
||
intensity: '',
|
||
required_level: '',
|
||
target_level: '',
|
||
},
|
||
])
|
||
setSkillPick('')
|
||
}
|
||
|
||
const setSkillPrimary = (idx) => {
|
||
updateFormField(
|
||
'skills',
|
||
formData.skills.map((s, i) => ({ ...s, is_primary: i === idx })),
|
||
)
|
||
}
|
||
|
||
const updateSkillField = (idx, field, value) => {
|
||
updateFormField(
|
||
'skills',
|
||
formData.skills.map((s, i) => (i === idx ? { ...s, [field]: value } : s)),
|
||
)
|
||
}
|
||
|
||
const removeSkillRow = (idx) => {
|
||
const next = formData.skills.filter((_, i) => i !== idx)
|
||
if (next.length && !next.some((s) => s.is_primary)) next[0].is_primary = true
|
||
updateFormField('skills', next)
|
||
}
|
||
|
||
const handleSubmit = async (e) => {
|
||
e.preventDefault()
|
||
if (!formData.title || formData.title.trim().length < 3) {
|
||
alert('Titel mindestens 3 Zeichen')
|
||
return
|
||
}
|
||
const payloadBase = {
|
||
...formData,
|
||
equipment:
|
||
typeof formData.equipmentLines === 'string'
|
||
? formData.equipmentLines
|
||
.split(/[\n,]+/)
|
||
.map((s) => s.trim())
|
||
.filter(Boolean)
|
||
: [],
|
||
}
|
||
let payload
|
||
try {
|
||
payload = buildExerciseApiPayload(payloadBase)
|
||
} catch (err) {
|
||
alert(err.message)
|
||
return
|
||
}
|
||
setSaving(true)
|
||
try {
|
||
if (isEdit) {
|
||
const saveOnce = (extras = {}) =>
|
||
api.updateExercise(exerciseId, buildExerciseApiPayload(payloadBase, extras))
|
||
try {
|
||
await saveOnce()
|
||
} catch (firstErr) {
|
||
if (
|
||
firstErr.status === 422 &&
|
||
firstErr.code === 'OFFICIAL_MEDIA_LIFECYCLE' &&
|
||
firstErr.payload?.media_assets
|
||
) {
|
||
alert(
|
||
'Speichern nicht möglich: mindestens ein verknüpftes Medium ist nicht aktiv (Papierkorb). ' +
|
||
'Bitte Medium wiederherstellen oder aus der Übung entfernen.',
|
||
)
|
||
throw firstErr
|
||
}
|
||
if (firstErr.status === 422 && firstErr.code === 'OFFICIAL_MEDIA_CONFIRM_REQUIRED') {
|
||
const promo = (firstErr.payload.assets_need_visibility_promotion || []).length
|
||
const miss = (firstErr.payload.assets_missing_copyright || []).length
|
||
let msg =
|
||
'Die Übung ist oder wird offiziell. '
|
||
if (promo > 0) {
|
||
msg += `${promo} verknüpfte Datei(en) werden dabei plattformweit („offiziell“) freigegeben. `
|
||
}
|
||
if (miss > 0) {
|
||
msg += `${miss} Datei(en) haben noch keinen ausreichenden Copyright-Vermerk (mind. 3 Zeichen). `
|
||
}
|
||
msg += 'Fortfahren?'
|
||
if (!window.confirm(msg)) throw firstErr
|
||
let defaultCopyright = ''
|
||
if (miss > 0) {
|
||
defaultCopyright = window.prompt(
|
||
'Copyright-Vermerk für betroffene Dateien ohne Eintrag (mind. 3 Zeichen):',
|
||
'© ',
|
||
)
|
||
if (!defaultCopyright || String(defaultCopyright).trim().length < 3) {
|
||
alert('Mindestens 3 Zeichen für den Copyright-Vermerk.')
|
||
throw firstErr
|
||
}
|
||
}
|
||
await saveOnce({
|
||
promote_attached_media_for_official: true,
|
||
...(miss > 0 ? { default_official_media_copyright: String(defaultCopyright).trim() } : {}),
|
||
})
|
||
} else if (
|
||
firstErr.status === 422 &&
|
||
firstErr.code === 'CLUB_MEDIA_COPYRIGHT_REQUIRED' &&
|
||
firstErr.payload?.media_assets
|
||
) {
|
||
alert(
|
||
'Vereinsöffentliche Übungen brauchen bei jeder verknüpften Datei einen Copyright-Vermerk (mind. 3 Zeichen). Bitte in der Medienbibliothek oder den Mediendetails nachtragen.',
|
||
)
|
||
throw firstErr
|
||
} else if (firstErr.status === 422 && firstErr.code === 'CLUB_MEDIA_LIFECYCLE') {
|
||
alert(
|
||
'Speichern nicht möglich: mindestens ein verknüpftes Medium ist nicht aktiv (Papierkorb). Bitte reaktivieren oder entfernen.',
|
||
)
|
||
throw firstErr
|
||
} else {
|
||
throw firstErr
|
||
}
|
||
}
|
||
const ex = await api.getExercise(exerciseId)
|
||
setMediaList(ex.media || [])
|
||
setVariants((ex.variants || []).map(apiVariantToRow))
|
||
alert('Gespeichert.')
|
||
} else {
|
||
const created = await api.createExercise(payload)
|
||
navigate(`/exercises/${created.id}/edit`, { replace: true })
|
||
}
|
||
} catch (err) {
|
||
alert('Fehler beim Speichern: ' + err.message)
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const refreshMedia = async () => {
|
||
if (!exerciseId) return
|
||
const ex = await api.getExercise(exerciseId)
|
||
setMediaList(ex.media || [])
|
||
}
|
||
|
||
const attachFromArchive = async (assetId) => {
|
||
if (!exerciseId) return
|
||
try {
|
||
await api.attachExerciseMediaFromAsset(exerciseId, {
|
||
media_asset_id: assetId,
|
||
context: archiveCtx,
|
||
title: '',
|
||
description: '',
|
||
is_primary: false,
|
||
})
|
||
setArchiveOpen(false)
|
||
await refreshMedia()
|
||
} catch (e) {
|
||
alert(e.message || String(e))
|
||
}
|
||
}
|
||
|
||
const linkedArchiveAssetIds = useMemo(
|
||
() => new Set((mediaList || []).map((m) => m.media_asset_id).filter(Boolean)),
|
||
[mediaList],
|
||
)
|
||
|
||
const handleUploadFile = async () => {
|
||
if (!exerciseId || mediaFiles.length === 0) {
|
||
alert('Datei(en) wählen')
|
||
return
|
||
}
|
||
const files = [...mediaFiles]
|
||
for (const file of files) {
|
||
const inferred = inferExerciseMediaType(file) || mediaType
|
||
const fd = new FormData()
|
||
fd.append('file', file)
|
||
fd.append('media_type', inferred)
|
||
fd.append('title', mediaTitle)
|
||
fd.append('description', '')
|
||
fd.append('context', mediaContext)
|
||
fd.append('is_primary', 'false')
|
||
try {
|
||
await api.uploadExerciseMedia(exerciseId, fd)
|
||
} catch (err) {
|
||
if (err.code === 'MEDIA_ASSET_IN_TRASH' && err.payload?.media_asset_id != null) {
|
||
const aid = err.payload.media_asset_id
|
||
const nameHint = file?.name || err.payload.original_filename || 'diese Datei'
|
||
if (
|
||
confirm(
|
||
`Die hochgeladene Datei ist inhaltsgleich mit einem Archiv-Medium im Papierkorb (${nameHint}). ` +
|
||
'Soll dieses Medium wieder aktiviert und an die Übung gehängt werden? (Es wird kein zweites Exemplar auf der Platte angelegt.)',
|
||
)
|
||
) {
|
||
try {
|
||
await api.postMediaAssetLifecycle(aid, 'reactivate')
|
||
await api.attachExerciseMediaFromAsset(exerciseId, {
|
||
media_asset_id: aid,
|
||
title: mediaTitle || undefined,
|
||
description: '',
|
||
context: mediaContext,
|
||
is_primary: false,
|
||
})
|
||
} catch (e2) {
|
||
alert(e2.message || String(e2))
|
||
return
|
||
}
|
||
} else {
|
||
return
|
||
}
|
||
} else if (err.code === 'MEDIA_ASSET_UNAVAILABLE') {
|
||
alert(
|
||
(err.message || 'Archiv-Konflikt') +
|
||
' Bitte wenden Sie sich an einen Administrator oder wählen Sie eine andere Datei.',
|
||
)
|
||
return
|
||
} else {
|
||
alert(`Upload (${file.name}): ${err.message || String(err)}`)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
setMediaFiles([])
|
||
setMediaTitle('')
|
||
await refreshMedia()
|
||
}
|
||
|
||
const handleAddEmbed = async () => {
|
||
if (!exerciseId || !embedUrl.trim()) {
|
||
alert('Embed-URL eingeben')
|
||
return
|
||
}
|
||
const fd = new FormData()
|
||
fd.append('embed_url', embedUrl.trim())
|
||
fd.append('media_type', 'video')
|
||
fd.append('title', embedTitle)
|
||
fd.append('description', '')
|
||
fd.append('context', mediaContext)
|
||
fd.append('is_primary', 'false')
|
||
try {
|
||
await api.uploadExerciseMedia(exerciseId, fd)
|
||
setEmbedUrl('')
|
||
setEmbedTitle('')
|
||
await refreshMedia()
|
||
} catch (err) {
|
||
alert('Embed: ' + err.message)
|
||
}
|
||
}
|
||
|
||
const handleDeleteMedia = async (mid) => {
|
||
if (
|
||
!confirm(
|
||
'Dieses Medium aus der Übung entfernen? Nur die Verknüpfung wird gelöscht. Die Datei bleibt im Archiv, solange sie noch woanders genutzt wird.',
|
||
)
|
||
) {
|
||
return
|
||
}
|
||
try {
|
||
const res = await api.deleteExerciseMedia(exerciseId, mid)
|
||
await refreshMedia()
|
||
const oid = res?.orphan_media_asset_id
|
||
if (oid != null) {
|
||
if (
|
||
confirm(
|
||
'Dieses Archiv-Medium wird danach nirgendwo mehr verwendet. In den Papierkorb (Stufe 1) legen? (Später in der Medienverwaltung wiederherstellbar.)',
|
||
)
|
||
) {
|
||
await api.postMediaAssetLifecycle(oid, 'trash_soft')
|
||
await refreshMedia()
|
||
}
|
||
}
|
||
} catch (err) {
|
||
alert(err.message)
|
||
}
|
||
}
|
||
|
||
const moveMediaRow = async (idx, dir) => {
|
||
if (!exerciseId) return
|
||
const j = idx + dir
|
||
if (j < 0 || j >= mediaList.length) return
|
||
const next = [...mediaList]
|
||
const tmp = next[idx]
|
||
next[idx] = next[j]
|
||
next[j] = tmp
|
||
try {
|
||
await api.reorderExerciseMedia(
|
||
exerciseId,
|
||
next.map((x) => x.id),
|
||
)
|
||
setMediaList(next)
|
||
} catch (e) {
|
||
alert(e.message || String(e))
|
||
}
|
||
}
|
||
|
||
const saveMediaMeta = async (mid) => {
|
||
if (!exerciseId) return
|
||
const fld = mediaFields[mid]
|
||
if (!fld) return
|
||
setMediaSavingId(mid)
|
||
try {
|
||
await api.updateExerciseMedia(exerciseId, mid, {
|
||
title: fld.title.trim() || null,
|
||
context: fld.context,
|
||
})
|
||
await refreshMedia()
|
||
} catch (e) {
|
||
alert(e.message || String(e))
|
||
} finally {
|
||
setMediaSavingId(null)
|
||
}
|
||
}
|
||
|
||
const refreshVariants = async () => {
|
||
if (!exerciseId) return
|
||
const ex = await api.getExercise(exerciseId)
|
||
setVariants((ex.variants || []).map(apiVariantToRow))
|
||
}
|
||
|
||
const updateVariantField = (id, patch) => {
|
||
setVariants((prev) => prev.map((v) => (v.id === id ? { ...v, ...patch } : v)))
|
||
}
|
||
|
||
const saveVariantRow = async (row) => {
|
||
const payload = buildVariantPayloadFromRow(row)
|
||
if (payload.variant_name.length < 3) {
|
||
alert('Variantenname mindestens 3 Zeichen')
|
||
return
|
||
}
|
||
setVariantSavingId(row.id)
|
||
try {
|
||
await api.updateExerciseVariant(exerciseId, row.id, payload)
|
||
await refreshVariants()
|
||
} catch (e) {
|
||
alert(e.message || String(e))
|
||
} finally {
|
||
setVariantSavingId(null)
|
||
}
|
||
}
|
||
|
||
const deleteVariantRow = async (id) => {
|
||
if (!confirm('Variante wirklich löschen?')) return
|
||
setVariantBusy(true)
|
||
try {
|
||
await api.deleteExerciseVariant(exerciseId, id)
|
||
if (variantEditSelection === id) setVariantEditSelection(null)
|
||
await refreshVariants()
|
||
} catch (e) {
|
||
alert(e.message || String(e))
|
||
} finally {
|
||
setVariantBusy(false)
|
||
}
|
||
}
|
||
|
||
const moveVariantRow = async (idx, dir) => {
|
||
const j = idx + dir
|
||
if (j < 0 || j >= variants.length) return
|
||
const next = [...variants]
|
||
const tmp = next[idx]
|
||
next[idx] = next[j]
|
||
next[j] = tmp
|
||
const ids = next.map((x) => x.id)
|
||
setVariantBusy(true)
|
||
try {
|
||
await api.reorderExerciseVariants(exerciseId, ids)
|
||
await refreshVariants()
|
||
} catch (e) {
|
||
alert(e.message || String(e))
|
||
} finally {
|
||
setVariantBusy(false)
|
||
}
|
||
}
|
||
|
||
const createVariantSubmit = async (e) => {
|
||
e.preventDefault()
|
||
if (!exerciseId) return
|
||
const payload = buildVariantPayloadFromRow(variantDraft)
|
||
if (payload.variant_name.length < 3) {
|
||
alert('Variantenname mindestens 3 Zeichen')
|
||
return
|
||
}
|
||
setVariantBusy(true)
|
||
try {
|
||
const created = await api.createExerciseVariant(exerciseId, payload)
|
||
setVariantDraft(emptyVariantDraft())
|
||
await refreshVariants()
|
||
if (created?.id != null) setVariantEditSelection(created.id)
|
||
else setVariantEditSelection(null)
|
||
} catch (err) {
|
||
alert(err.message || String(err))
|
||
} finally {
|
||
setVariantBusy(false)
|
||
}
|
||
}
|
||
|
||
const availableSkills = skillsCatalog.filter((s) => !formData.skills.some((x) => x.skill_id === s.id))
|
||
|
||
const selectedVariantForEdit =
|
||
typeof variantEditSelection === 'number' ? variants.find((v) => v.id === variantEditSelection) : null
|
||
const selectedVariantIdx = selectedVariantForEdit
|
||
? variants.findIndex((v) => v.id === selectedVariantForEdit.id)
|
||
: -1
|
||
|
||
if (loading) {
|
||
return (
|
||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||
<div className="spinner"></div>
|
||
<p>Laden...</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div style={{ padding: '12px' }} className="app-page">
|
||
<div style={{ marginBottom: '12px' }}>
|
||
<button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}>
|
||
← Übersicht
|
||
</button>
|
||
{isEdit && (
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
style={{ marginLeft: '8px' }}
|
||
onClick={() => navigate(`/exercises/${exerciseId}`)}
|
||
>
|
||
Ansehen
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="card">
|
||
<h1 style={{ marginTop: 0, fontSize: '1.25rem' }}>{isEdit ? 'Übung bearbeiten' : 'Neue Übung'}</h1>
|
||
|
||
<form onSubmit={handleSubmit}>
|
||
<div className="form-row">
|
||
<label className="form-label">Titel *</label>
|
||
<input
|
||
type="text"
|
||
className="form-input"
|
||
value={formData.title}
|
||
onChange={(e) => updateFormField('title', e.target.value)}
|
||
required
|
||
minLength={3}
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<label className="form-label">Kurzbeschreibung</label>
|
||
<RichTextEditor
|
||
value={formData.summary}
|
||
onChange={(html) => updateFormField('summary', html)}
|
||
placeholder="Kurzbeschreibung (optional)"
|
||
minHeight="80px"
|
||
insertExerciseMediaSlots={exerciseMediaInsertSlots}
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<label className="form-label">Ziel *</label>
|
||
<RichTextEditor
|
||
value={formData.goal}
|
||
onChange={(html) => updateFormField('goal', html)}
|
||
placeholder="Trainingsziel"
|
||
minHeight="120px"
|
||
insertExerciseMediaSlots={exerciseMediaInsertSlots}
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<label className="form-label">Durchführung *</label>
|
||
<RichTextEditor
|
||
value={formData.execution}
|
||
onChange={(html) => updateFormField('execution', html)}
|
||
placeholder="Ablauf Schritt für Schritt"
|
||
minHeight="180px"
|
||
insertExerciseMediaSlots={exerciseMediaInsertSlots}
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<label className="form-label">Vorbereitung / Aufbau</label>
|
||
<RichTextEditor
|
||
value={formData.preparation}
|
||
onChange={(html) => updateFormField('preparation', html)}
|
||
placeholder="Matten, Raum, …"
|
||
minHeight="100px"
|
||
insertExerciseMediaSlots={exerciseMediaInsertSlots}
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<label className="form-label">Hinweise für Trainer</label>
|
||
<RichTextEditor
|
||
value={formData.trainer_notes}
|
||
onChange={(html) => updateFormField('trainer_notes', html)}
|
||
placeholder="Sicherheit, Varianten-Hinweise, …"
|
||
minHeight="100px"
|
||
insertExerciseMediaSlots={exerciseMediaInsertSlots}
|
||
/>
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<label className="form-label">Material (eine Zeile oder kommagetrennt)</label>
|
||
<textarea
|
||
className="form-input"
|
||
rows={3}
|
||
value={formData.equipmentLines}
|
||
onChange={(e) => updateFormField('equipmentLines', e.target.value)}
|
||
placeholder="Matten Pratzen"
|
||
/>
|
||
</div>
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||
<div className="form-row">
|
||
<label className="form-label">Dauer Min</label>
|
||
<input
|
||
type="number"
|
||
className="form-input"
|
||
value={formData.duration_min}
|
||
onChange={(e) =>
|
||
updateFormField('duration_min', e.target.value ? parseInt(e.target.value, 10) : '')
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="form-row">
|
||
<label className="form-label">Dauer Max</label>
|
||
<input
|
||
type="number"
|
||
className="form-input"
|
||
value={formData.duration_max}
|
||
onChange={(e) =>
|
||
updateFormField('duration_max', e.target.value ? parseInt(e.target.value, 10) : '')
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||
<div className="form-row">
|
||
<label className="form-label">Gruppe Min</label>
|
||
<input
|
||
type="number"
|
||
className="form-input"
|
||
value={formData.group_size_min}
|
||
onChange={(e) =>
|
||
updateFormField('group_size_min', e.target.value ? parseInt(e.target.value, 10) : '')
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="form-row">
|
||
<label className="form-label">Gruppe Max</label>
|
||
<input
|
||
type="number"
|
||
className="form-input"
|
||
value={formData.group_size_max}
|
||
onChange={(e) =>
|
||
updateFormField('group_size_max', e.target.value ? parseInt(e.target.value, 10) : '')
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<MultiAssocBlock
|
||
title="Fokusbereiche (0…n, ein „primär“)"
|
||
rows={formData.focus_areas_multi}
|
||
setRows={(r) => updateFormField('focus_areas_multi', r)}
|
||
options={focusAreas}
|
||
idKey="focus_area_id"
|
||
emptyLabel="Keine Zuordnung — optional „+ Eintrag“."
|
||
/>
|
||
|
||
<MultiAssocBlock
|
||
title="Stilrichtungen (0…n, z. B. Shotokan)"
|
||
rows={formData.training_styles_multi}
|
||
setRows={(r) => updateFormField('training_styles_multi', r)}
|
||
options={styleDirections.map((sd) => ({
|
||
...sd,
|
||
name: sd.parent_style_name ? `${sd.name} (${sd.parent_style_name})` : sd.name,
|
||
}))}
|
||
idKey="training_style_id"
|
||
emptyLabel="Keine Stilrichtung gewählt."
|
||
/>
|
||
|
||
<MultiAssocBlock
|
||
title="Trainingsstil (0…n, z. B. Breitensport / Leistungssport)"
|
||
rows={formData.training_types_multi}
|
||
setRows={(r) => updateFormField('training_types_multi', r)}
|
||
options={trainingTypes}
|
||
idKey="training_type_id"
|
||
emptyLabel="Kein Trainingsstil gewählt."
|
||
/>
|
||
|
||
<MultiAssocBlock
|
||
title="Zielgruppen (0…n)"
|
||
rows={formData.target_groups_multi}
|
||
setRows={(r) => updateFormField('target_groups_multi', r)}
|
||
options={targetGroups}
|
||
idKey="target_group_id"
|
||
emptyLabel="Keine Zielgruppe gewählt."
|
||
/>
|
||
|
||
<div className="form-row">
|
||
<label className="form-label">Fähigkeiten (je Übung mehrere, mit Niveau)</label>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '10px' }}>
|
||
<select
|
||
className="form-input"
|
||
style={{ flex: '1 1 200px' }}
|
||
value={skillPick}
|
||
onChange={(e) => setSkillPick(e.target.value)}
|
||
>
|
||
<option value="">Fähigkeit wählen…</option>
|
||
{availableSkills.map((s) => (
|
||
<option key={s.id} value={s.id}>
|
||
{s.name} ({s.category})
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button type="button" className="btn btn-secondary" onClick={addSkillRow}>
|
||
Hinzufügen
|
||
</button>
|
||
</div>
|
||
{formData.skills.map((row, idx) => {
|
||
const sk = skillsCatalog.find((s) => s.id === row.skill_id)
|
||
return (
|
||
<div key={`${row.skill_id}-${idx}`} className="skills-editor-row">
|
||
<div>
|
||
<strong style={{ fontSize: '14px' }}>{sk?.name || `Skill #${row.skill_id}`}</strong>
|
||
{sk?.category && (
|
||
<span style={{ color: 'var(--text2)', fontSize: '12px', marginLeft: '6px' }}>
|
||
{sk.category}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<label style={{ fontSize: '12px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||
<input
|
||
type="radio"
|
||
name="skill-primary"
|
||
checked={row.is_primary}
|
||
onChange={() => setSkillPrimary(idx)}
|
||
/>
|
||
primär
|
||
</label>
|
||
<select
|
||
className="form-input"
|
||
value={row.intensity || ''}
|
||
onChange={(e) => updateSkillField(idx, 'intensity', e.target.value)}
|
||
>
|
||
{INTENSITY_OPTIONS.map((o) => (
|
||
<option key={o.value || 'i'} value={o.value}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
className="form-input"
|
||
value={row.required_level || ''}
|
||
onChange={(e) => updateSkillField(idx, 'required_level', e.target.value)}
|
||
>
|
||
{SKILL_LEVEL_OPTIONS.map((o) => (
|
||
<option key={`r-${o.value}`} value={o.value}>
|
||
von {o.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
className="form-input"
|
||
value={row.target_level || ''}
|
||
onChange={(e) => updateSkillField(idx, 'target_level', e.target.value)}
|
||
>
|
||
{SKILL_LEVEL_OPTIONS.map((o) => (
|
||
<option key={`t-${o.value}`} value={o.value}>
|
||
bis {o.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => removeSkillRow(idx)}>
|
||
Entf.
|
||
</button>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||
<div className="form-row">
|
||
<label className="form-label">Sichtbarkeit</label>
|
||
<select
|
||
className="form-input"
|
||
value={formData.visibility}
|
||
onChange={(e) => updateFormField('visibility', e.target.value)}
|
||
>
|
||
<option value="private">Privat</option>
|
||
<option value="club">Verein</option>
|
||
{isSuperadmin ? <option value="official">Offiziell</option> : null}
|
||
</select>
|
||
</div>
|
||
<div className="form-row">
|
||
<label className="form-label">Status</label>
|
||
<select
|
||
className="form-input"
|
||
value={formData.status}
|
||
onChange={(e) => updateFormField('status', e.target.value)}
|
||
>
|
||
<option value="draft">Entwurf</option>
|
||
<option value="in_review">In Prüfung</option>
|
||
<option value="approved">Freigegeben</option>
|
||
<option value="archived">Archiviert</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ marginTop: '16px' }}>
|
||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||
{saving ? 'Speichern…' : isEdit ? 'Speichern' : 'Anlegen & weiter'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
{isEdit && (
|
||
<details ref={variantsDetailsRef} className="card exercise-variants-details" style={{ marginTop: '16px' }}>
|
||
<summary className="exercise-variants-summary">
|
||
<span className="exercise-variants-summary__title">Übungsvarianten</span>
|
||
<span className="exercise-variants-summary__badge">
|
||
{variants.length === 0
|
||
? 'keine'
|
||
: `${variants.length} ${variants.length === 1 ? 'Variante' : 'Varianten'}`}
|
||
</span>
|
||
</summary>
|
||
<div className="exercise-variants-details__body">
|
||
<p className="exercise-variants-hint">
|
||
Pro Durchgang nur eine Variante bearbeiten – weniger Scrollen. Reihenfolge entspricht Planung und Auswahl im
|
||
Training; „Voraussetzung“ nutzt ihr später für Progressions-Serien.
|
||
</p>
|
||
|
||
{variants.length > 0 && (
|
||
<div className="form-row">
|
||
<label className="form-label" htmlFor="variant-edit-select">
|
||
Variante auswählen
|
||
</label>
|
||
<select
|
||
id="variant-edit-select"
|
||
className="form-input"
|
||
value={
|
||
variantEditSelection === 'new'
|
||
? 'new'
|
||
: variantEditSelection == null
|
||
? ''
|
||
: String(variantEditSelection)
|
||
}
|
||
onChange={(e) => {
|
||
const val = e.target.value
|
||
if (val === '') setVariantEditSelection(null)
|
||
else if (val === 'new') setVariantEditSelection('new')
|
||
else setVariantEditSelection(parseInt(val, 10))
|
||
}}
|
||
>
|
||
<option value="">— nicht bearbeiten —</option>
|
||
{variants.map((v) => (
|
||
<option key={v.id} value={v.id}>
|
||
{(v.variant_name && String(v.variant_name).trim()) || `Variante #${v.id}`}
|
||
</option>
|
||
))}
|
||
<option value="new">+ Neue Variante anlegen…</option>
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
{variants.length === 0 && (
|
||
<p style={{ fontSize: '13px', color: 'var(--text3)', marginBottom: '10px' }}>
|
||
Noch keine Varianten – optional für andere Ausführung, Dauer oder Material in Planung und Training.
|
||
</p>
|
||
)}
|
||
|
||
{variants.length === 0 && variantEditSelection !== 'new' && (
|
||
<button type="button" className="btn btn-secondary" onClick={() => setVariantEditSelection('new')}>
|
||
Erste Variante anlegen
|
||
</button>
|
||
)}
|
||
|
||
{variantEditSelection === 'new' && (
|
||
<form
|
||
className="exercise-variant-single-form"
|
||
onSubmit={createVariantSubmit}
|
||
style={{ marginTop: '14px', paddingTop: '14px', borderTop: '1px solid var(--border)' }}
|
||
>
|
||
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Neue Variante</h3>
|
||
<ExerciseVariantFields
|
||
row={variantDraft}
|
||
onPatch={(patch) => setVariantDraft((d) => ({ ...d, ...patch }))}
|
||
prerequisiteOthers={variants}
|
||
rteMinHeight="110px"
|
||
exerciseMediaInsertSlots={exerciseMediaInsertSlots}
|
||
/>
|
||
<button type="submit" className="btn btn-primary" style={{ marginTop: '10px' }} disabled={variantBusy}>
|
||
{variantBusy ? 'Anlegen…' : 'Variante anlegen'}
|
||
</button>
|
||
</form>
|
||
)}
|
||
|
||
{selectedVariantForEdit && (
|
||
<div
|
||
className="exercise-variant-single-form"
|
||
style={{ marginTop: '14px', paddingTop: '14px', borderTop: '1px solid var(--border)' }}
|
||
>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: '8px',
|
||
alignItems: 'center',
|
||
marginBottom: '12px',
|
||
}}
|
||
>
|
||
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
|
||
Pos. {selectedVariantIdx + 1} von {variants.length}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||
disabled={variantBusy || selectedVariantIdx <= 0}
|
||
onClick={() => moveVariantRow(selectedVariantIdx, -1)}
|
||
>
|
||
Nach oben
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||
disabled={variantBusy || selectedVariantIdx >= variants.length - 1}
|
||
onClick={() => moveVariantRow(selectedVariantIdx, 1)}
|
||
>
|
||
Nach unten
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary"
|
||
style={{ marginLeft: 'auto', fontSize: '12px' }}
|
||
disabled={variantSavingId === selectedVariantForEdit.id || variantBusy}
|
||
onClick={() => saveVariantRow(selectedVariantForEdit)}
|
||
>
|
||
{variantSavingId === selectedVariantForEdit.id ? 'Speichern…' : 'Speichern'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn"
|
||
style={{ fontSize: '12px', background: 'var(--danger)', color: '#fff', border: 'none' }}
|
||
disabled={variantBusy}
|
||
onClick={() => deleteVariantRow(selectedVariantForEdit.id)}
|
||
>
|
||
Löschen
|
||
</button>
|
||
</div>
|
||
<ExerciseVariantFields
|
||
row={selectedVariantForEdit}
|
||
onPatch={(patch) => updateVariantField(selectedVariantForEdit.id, patch)}
|
||
prerequisiteOthers={variants.filter((o) => o.id !== selectedVariantForEdit.id)}
|
||
rteMinHeight="110px"
|
||
exerciseMediaInsertSlots={exerciseMediaInsertSlots}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{variants.length > 0 && variantEditSelection == null && (
|
||
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: '12px', marginBottom: 0 }}>
|
||
Wähle eine Variante zum Bearbeiten oder „Neue Variante anlegen…“.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</details>
|
||
)}
|
||
|
||
{isEdit && (
|
||
<details className="card exercise-variants-details" style={{ marginTop: '16px' }}>
|
||
<summary className="exercise-variants-summary">
|
||
<span className="exercise-variants-summary__title">Progressionsgraph</span>
|
||
<span className="exercise-variants-summary__badge">Übung → Übung</span>
|
||
</summary>
|
||
<div className="exercise-variants-details__body">
|
||
<ExerciseProgressionGraphPanel anchorExerciseId={exerciseId} anchorTitle={formData.title} />
|
||
</div>
|
||
</details>
|
||
)}
|
||
|
||
{isEdit && (
|
||
<div className="card" style={{ marginTop: '16px' }}>
|
||
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Medien</h2>
|
||
<p style={{ color: 'var(--text2)', fontSize: '13px' }}>
|
||
Datei oder Embed (YouTube, Vimeo, Instagram, TikTok). Max. 10 pro Übung.
|
||
</p>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: '8px',
|
||
alignItems: 'center',
|
||
marginTop: '10px',
|
||
}}
|
||
>
|
||
<button type="button" className="btn btn-secondary" onClick={() => setArchiveOpen(true)}>
|
||
Aus Archiv verknüpfen…
|
||
</button>
|
||
<Link to="/media" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
|
||
Medienbibliothek
|
||
</Link>
|
||
</div>
|
||
<div style={{ display: 'grid', gap: '12px', marginTop: '12px' }}>
|
||
<div>
|
||
<label className="form-label">Dateien</label>
|
||
<input
|
||
type="file"
|
||
multiple
|
||
accept="image/*,video/*,application/pdf"
|
||
onChange={(e) => {
|
||
setMediaFiles(Array.from(e.target.files || []))
|
||
e.target.value = ''
|
||
}}
|
||
/>
|
||
{mediaFiles.length > 0 && (
|
||
<div style={{ fontSize: '0.875rem', color: 'var(--text2)', marginTop: '6px' }}>
|
||
{mediaFiles.length} Datei(en): {mediaFiles.map((f) => f.name).join(', ')}
|
||
</div>
|
||
)}
|
||
<div className="form-row" style={{ marginTop: '8px' }}>
|
||
<select className="form-input" value={mediaType} onChange={(e) => setMediaType(e.target.value)}>
|
||
<option value="image">Typ-Fallback: Bild</option>
|
||
<option value="video">Typ-Fallback: Video</option>
|
||
<option value="document">Typ-Fallback: PDF</option>
|
||
</select>
|
||
<input
|
||
type="text"
|
||
className="form-input"
|
||
placeholder="Titel (optional)"
|
||
value={mediaTitle}
|
||
onChange={(e) => setMediaTitle(e.target.value)}
|
||
style={{ marginTop: '8px' }}
|
||
/>
|
||
<select
|
||
className="form-input"
|
||
value={mediaContext}
|
||
onChange={(e) => setMediaContext(e.target.value)}
|
||
style={{ marginTop: '8px' }}
|
||
>
|
||
<option value="ablauf">Ablauf</option>
|
||
<option value="detail">Detail</option>
|
||
<option value="trainer_hint">Trainer-Hinweis</option>
|
||
</select>
|
||
</div>
|
||
<button type="button" className="btn btn-secondary" style={{ marginTop: '8px' }} onClick={handleUploadFile}>
|
||
Hochladen
|
||
</button>
|
||
</div>
|
||
<div>
|
||
<label className="form-label">Embed-URL</label>
|
||
<input
|
||
type="url"
|
||
className="form-input"
|
||
placeholder="https://…"
|
||
value={embedUrl}
|
||
onChange={(e) => setEmbedUrl(e.target.value)}
|
||
/>
|
||
<input
|
||
type="text"
|
||
className="form-input"
|
||
placeholder="Titel (optional)"
|
||
value={embedTitle}
|
||
onChange={(e) => setEmbedTitle(e.target.value)}
|
||
style={{ marginTop: '8px' }}
|
||
/>
|
||
<button type="button" className="btn btn-secondary" style={{ marginTop: '8px' }} onClick={handleAddEmbed}>
|
||
Embed hinzufügen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{mediaList.length > 0 && (
|
||
<ul style={{ marginTop: '12px', paddingLeft: '0', listStyle: 'none' }}>
|
||
{mediaList.map((m, idx) => (
|
||
<li
|
||
key={m.id}
|
||
className="card"
|
||
style={{
|
||
marginBottom: '10px',
|
||
padding: '10px 12px',
|
||
border: '1px solid var(--border)',
|
||
display: 'flex',
|
||
gap: '12px',
|
||
alignItems: 'flex-start',
|
||
}}
|
||
>
|
||
<ExerciseMediaThumbTile exerciseId={exerciseId} media={m} onOpenPreview={setMediaPreview} />
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
|
||
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
|
||
#{idx + 1} · {m.media_type}
|
||
{m.embed_platform ? ` · ${m.embed_platform}` : ''}
|
||
</span>
|
||
{mediaList.length > 1 && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
style={{ fontSize: '11px', padding: '2px 8px' }}
|
||
disabled={idx === 0}
|
||
onClick={() => moveMediaRow(idx, -1)}
|
||
title="Nach oben"
|
||
>
|
||
↑
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
style={{ fontSize: '11px', padding: '2px 8px' }}
|
||
disabled={idx >= mediaList.length - 1}
|
||
onClick={() => moveMediaRow(idx, 1)}
|
||
title="Nach unten"
|
||
>
|
||
↓
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
<div
|
||
style={{
|
||
fontSize: '12px',
|
||
color: 'var(--text2)',
|
||
marginTop: '6px',
|
||
wordBreak: 'break-word',
|
||
lineHeight: 1.35,
|
||
}}
|
||
>
|
||
{(m.original_filename || '').trim() ||
|
||
(m.title || '').trim() ||
|
||
(m.embed_url ? m.embed_url : '') ||
|
||
'—'}
|
||
</div>
|
||
<div className="form-row" style={{ marginTop: '8px', display: 'grid', gap: '8px' }}>
|
||
<input
|
||
type="text"
|
||
className="form-input"
|
||
placeholder="Titel"
|
||
value={(mediaFields[m.id] || {}).title ?? ''}
|
||
onChange={(e) =>
|
||
setMediaFields((prev) => ({
|
||
...prev,
|
||
[m.id]: { ...(prev[m.id] || {}), title: e.target.value, context: (prev[m.id] || {}).context || 'ablauf' },
|
||
}))
|
||
}
|
||
/>
|
||
<select
|
||
className="form-input"
|
||
value={(mediaFields[m.id] || {}).context || 'ablauf'}
|
||
onChange={(e) =>
|
||
setMediaFields((prev) => ({
|
||
...prev,
|
||
[m.id]: {
|
||
...(prev[m.id] || {}),
|
||
title: (prev[m.id] || {}).title ?? '',
|
||
context: e.target.value,
|
||
},
|
||
}))
|
||
}
|
||
>
|
||
<option value="ablauf">Ablauf</option>
|
||
<option value="detail">Detail</option>
|
||
<option value="trainer_hint">Trainer-Hinweis</option>
|
||
</select>
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary"
|
||
style={{ fontSize: '12px' }}
|
||
disabled={mediaSavingId === m.id}
|
||
onClick={() => saveMediaMeta(m.id)}
|
||
>
|
||
{mediaSavingId === m.id ? 'Speichern…' : 'Titel & Sektion speichern'}
|
||
</button>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
style={{
|
||
marginTop: '8px',
|
||
fontSize: '12px',
|
||
padding: '6px 12px',
|
||
}}
|
||
onClick={() => handleDeleteMedia(m.id)}
|
||
>
|
||
Aus Übung entfernen
|
||
</button>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
{archiveOpen && (
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="Medienarchiv"
|
||
style={{
|
||
position: 'fixed',
|
||
inset: 0,
|
||
background: 'rgba(0,0,0,0.5)',
|
||
zIndex: 1000,
|
||
overflow: 'auto',
|
||
padding: '16px',
|
||
}}
|
||
onClick={() => setArchiveOpen(false)}
|
||
onKeyDown={(e) => e.key === 'Escape' && setArchiveOpen(false)}
|
||
>
|
||
<div
|
||
className="card"
|
||
style={{
|
||
maxWidth: 560,
|
||
margin: '4vh auto',
|
||
maxHeight: '88vh',
|
||
overflow: 'auto',
|
||
position: 'relative',
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<h3 style={{ marginTop: 0, fontSize: '1.05rem' }}>Medienarchiv</h3>
|
||
<input
|
||
type="search"
|
||
className="form-input"
|
||
placeholder="Suche Dateiname…"
|
||
value={archiveQ}
|
||
onChange={(e) => setArchiveQ(e.target.value)}
|
||
style={{ marginBottom: '8px' }}
|
||
/>
|
||
<select
|
||
className="form-input"
|
||
value={archiveCtx}
|
||
onChange={(e) => setArchiveCtx(e.target.value)}
|
||
style={{ marginBottom: '12px' }}
|
||
>
|
||
<option value="ablauf">Sektion: Ablauf</option>
|
||
<option value="detail">Sektion: Detail</option>
|
||
<option value="trainer_hint">Sektion: Trainer-Hinweis</option>
|
||
</select>
|
||
{archiveLoading && <p style={{ fontSize: '13px', color: 'var(--text3)' }}>Laden…</p>}
|
||
{archiveError && <p style={{ fontSize: '13px', color: 'var(--danger)' }}>{archiveError}</p>}
|
||
{!archiveLoading && !archiveError && archiveItems.length === 0 && (
|
||
<p style={{ fontSize: '13px', color: 'var(--text3)' }}>Keine Treffer.</p>
|
||
)}
|
||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||
{archiveItems.map((a) => {
|
||
const already = linkedArchiveAssetIds.has(a.id)
|
||
return (
|
||
<li
|
||
key={a.id}
|
||
style={{
|
||
display: 'flex',
|
||
gap: '10px',
|
||
alignItems: 'center',
|
||
padding: '8px 0',
|
||
borderBottom: '1px solid var(--border)',
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: 56,
|
||
height: 56,
|
||
flexShrink: 0,
|
||
borderRadius: '6px',
|
||
overflow: 'hidden',
|
||
background: 'var(--surface2, rgba(127,127,127,0.12))',
|
||
border: '1px solid var(--border)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
{a.mime_type?.startsWith('image/') ? (
|
||
<img
|
||
alt=""
|
||
src={resolveMediaAssetFileUrl(a.id)}
|
||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||
/>
|
||
) : a.mime_type?.startsWith('video/') ? (
|
||
<span style={{ fontSize: '18px', opacity: 0.75 }} aria-hidden>
|
||
▶
|
||
</span>
|
||
) : (
|
||
<span style={{ fontSize: '10px', color: 'var(--text2)', padding: '2px', textAlign: 'center' }}>
|
||
PDF
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontSize: '13px', fontWeight: 600, wordBreak: 'break-word' }}>
|
||
{a.original_filename || `Asset #${a.id}`}
|
||
</div>
|
||
<div style={{ fontSize: '11px', color: 'var(--text3)', marginTop: '2px' }}>
|
||
{a.visibility} · {a.mime_type || '—'}{' '}
|
||
{a.byte_size != null ? `· ${(a.byte_size / 1024).toFixed(0)} KB` : ''}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary"
|
||
style={{ fontSize: '12px' }}
|
||
disabled={already}
|
||
title={already ? 'Schon mit dieser Übung verknüpft' : ''}
|
||
onClick={() => !already && attachFromArchive(a.id)}
|
||
>
|
||
{already ? 'Bereits verknüpft' : 'Verknüpfen'}
|
||
</button>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
<div style={{ marginTop: '16px', display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||
<button type="button" className="btn btn-secondary" onClick={() => setArchiveOpen(false)}>
|
||
Schließen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{mediaPreview && (
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="Medienvorschau"
|
||
style={{
|
||
position: 'fixed',
|
||
inset: 0,
|
||
background: 'rgba(0,0,0,0.55)',
|
||
zIndex: 1001,
|
||
overflow: 'auto',
|
||
padding: '16px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
onClick={() => setMediaPreview(null)}
|
||
onKeyDown={(e) => e.key === 'Escape' && setMediaPreview(null)}
|
||
>
|
||
<div
|
||
className="card"
|
||
style={{ maxWidth: 720, width: '100%', maxHeight: '90vh', overflow: 'auto' }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<h3 style={{ marginTop: 0, fontSize: '1.05rem' }}>Vorschau</h3>
|
||
{mediaPreview.embed_url ? (
|
||
<p style={{ fontSize: '14px', wordBreak: 'break-all' }}>
|
||
<a href={mediaPreview.embed_url} target="_blank" rel="noreferrer">
|
||
{mediaPreview.embed_url}
|
||
</a>
|
||
</p>
|
||
) : mediaPreview.mime_type?.startsWith('video/') || mediaPreview.media_type === 'video' ? (
|
||
<video
|
||
src={resolveExerciseMediaFileUrl(exerciseId, mediaPreview)}
|
||
controls
|
||
style={{ width: '100%', borderRadius: '8px', maxHeight: '70vh' }}
|
||
/>
|
||
) : mediaPreview.mime_type?.startsWith('image/') || mediaPreview.media_type === 'image' ? (
|
||
<img
|
||
alt=""
|
||
src={resolveExerciseMediaFileUrl(exerciseId, mediaPreview)}
|
||
style={{ maxWidth: '100%', borderRadius: '8px', maxHeight: '70vh', objectFit: 'contain' }}
|
||
/>
|
||
) : (
|
||
<p style={{ fontSize: '14px' }}>
|
||
<a href={resolveExerciseMediaFileUrl(exerciseId, mediaPreview)} target="_blank" rel="noreferrer">
|
||
Datei öffnen
|
||
</a>
|
||
</p>
|
||
)}
|
||
<div style={{ marginTop: '16px' }}>
|
||
<button type="button" className="btn btn-secondary" onClick={() => setMediaPreview(null)}>
|
||
Schließen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}>
|
||
<strong>KI-Ausbaustufe:</strong> Backend laut Spec{' '}
|
||
<code style={{ fontSize: '11px' }}>POST /api/exercises/ai/suggest</code> und{' '}
|
||
<code style={{ fontSize: '11px' }}>POST /api/exercises/{'{id}'}/ai/regenerate</code> — z. B.{' '}
|
||
<code>OPENROUTER_API_KEY</code>, Vorschläge nur nach Trainer-Bestätigung übernehmen (siehe{' '}
|
||
<code>api.suggestExerciseAi</code>).
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default ExerciseFormPage
|