697 lines
24 KiB
React
697 lines
24 KiB
React
|
|
import React, { useEffect, useState } from 'react'
|
||
|
|
import { useNavigate, useParams } from 'react-router-dom'
|
||
|
|
import api, { buildExerciseApiPayload } from '../utils/api'
|
||
|
|
|
||
|
|
const INTENSITY_OPTIONS = [
|
||
|
|
{ value: '', label: '—' },
|
||
|
|
{ value: 'niedrig', label: 'niedrig' },
|
||
|
|
{ value: 'mittel', label: 'mittel' },
|
||
|
|
{ value: 'hoch', label: 'hoch' },
|
||
|
|
]
|
||
|
|
|
||
|
|
const LEVEL_OPTIONS = [
|
||
|
|
{ value: '', label: '—' },
|
||
|
|
{ value: 'einsteiger', label: 'Einsteiger' },
|
||
|
|
{ value: 'grundlagen', label: 'Grundlagen' },
|
||
|
|
{ value: 'aufbau', label: 'Aufbau' },
|
||
|
|
{ value: 'fortgeschritten', label: 'Fortgeschritten' },
|
||
|
|
{ value: 'experte', label: 'Experte' },
|
||
|
|
]
|
||
|
|
|
||
|
|
function emptyForm() {
|
||
|
|
return {
|
||
|
|
title: '',
|
||
|
|
summary: '',
|
||
|
|
goal: '',
|
||
|
|
execution: '',
|
||
|
|
preparation: '',
|
||
|
|
trainer_notes: '',
|
||
|
|
equipment: [],
|
||
|
|
duration_min: '',
|
||
|
|
duration_max: '',
|
||
|
|
group_size_min: '',
|
||
|
|
group_size_max: '',
|
||
|
|
age_groups: [],
|
||
|
|
focus_area_id: null,
|
||
|
|
training_style_id: null,
|
||
|
|
visibility: 'private',
|
||
|
|
status: 'draft',
|
||
|
|
skills: [],
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function detailToForm(exercise) {
|
||
|
|
const primaryFa = exercise.focus_areas?.find((f) => f.is_primary) || exercise.focus_areas?.[0]
|
||
|
|
const primaryTs =
|
||
|
|
exercise.training_styles?.find((t) => t.is_primary) || exercise.training_styles?.[0]
|
||
|
|
return {
|
||
|
|
title: exercise.title || '',
|
||
|
|
summary: exercise.summary || '',
|
||
|
|
goal: exercise.goal || '',
|
||
|
|
execution: exercise.execution || '',
|
||
|
|
preparation: exercise.preparation || '',
|
||
|
|
trainer_notes: exercise.trainer_notes || '',
|
||
|
|
equipment: exercise.equipment || [],
|
||
|
|
duration_min: exercise.duration_min ?? '',
|
||
|
|
duration_max: exercise.duration_max ?? '',
|
||
|
|
group_size_min: exercise.group_size_min ?? '',
|
||
|
|
group_size_max: exercise.group_size_max ?? '',
|
||
|
|
age_groups: exercise.age_groups || [],
|
||
|
|
focus_area_id: primaryFa?.focus_area_id ?? null,
|
||
|
|
training_style_id: primaryTs?.training_style_id ?? null,
|
||
|
|
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: s.required_level || '',
|
||
|
|
target_level: s.target_level || '',
|
||
|
|
})) || [],
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function ExerciseFormPage() {
|
||
|
|
const { id: routeId } = useParams()
|
||
|
|
const navigate = useNavigate()
|
||
|
|
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 [trainingStyles, setTrainingStyles] = useState([])
|
||
|
|
const [mediaList, setMediaList] = useState([])
|
||
|
|
const [loading, setLoading] = useState(!!isEdit)
|
||
|
|
const [saving, setSaving] = useState(false)
|
||
|
|
|
||
|
|
const [mediaFile, setMediaFile] = useState(null)
|
||
|
|
const [mediaType, setMediaType] = useState('image')
|
||
|
|
const [mediaTitle, setMediaTitle] = useState('')
|
||
|
|
const [mediaContext, setMediaContext] = useState('ablauf')
|
||
|
|
const [embedUrl, setEmbedUrl] = useState('')
|
||
|
|
const [embedTitle, setEmbedTitle] = useState('')
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
let cancelled = false
|
||
|
|
const boot = async () => {
|
||
|
|
try {
|
||
|
|
const [skillsData, faData, tsData] = await Promise.all([
|
||
|
|
api.listSkills(),
|
||
|
|
api.listFocusAreas(),
|
||
|
|
api.listTrainingStyles(),
|
||
|
|
])
|
||
|
|
if (cancelled) return
|
||
|
|
setSkillsCatalog(skillsData)
|
||
|
|
setFocusAreas(faData)
|
||
|
|
setTrainingStyles(tsData)
|
||
|
|
} catch (e) {
|
||
|
|
if (!cancelled) console.error(e)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
boot()
|
||
|
|
return () => {
|
||
|
|
cancelled = true
|
||
|
|
}
|
||
|
|
}, [])
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!isEdit) {
|
||
|
|
setFormData(emptyForm())
|
||
|
|
setMediaList([])
|
||
|
|
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 || [])
|
||
|
|
} catch (err) {
|
||
|
|
if (!cancelled) {
|
||
|
|
alert(err.message || 'Übung nicht ladbar')
|
||
|
|
navigate('/exercises')
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
if (!cancelled) setLoading(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
load()
|
||
|
|
return () => {
|
||
|
|
cancelled = true
|
||
|
|
}
|
||
|
|
}, [isEdit, exerciseId, navigate])
|
||
|
|
|
||
|
|
const updateFormField = (field, value) => {
|
||
|
|
setFormData((prev) => ({ ...prev, [field]: value }))
|
||
|
|
}
|
||
|
|
|
||
|
|
const toggleSkill = (skillId) => {
|
||
|
|
const existing = formData.skills.find((s) => s.skill_id === skillId)
|
||
|
|
if (existing) {
|
||
|
|
updateFormField(
|
||
|
|
'skills',
|
||
|
|
formData.skills.filter((s) => s.skill_id !== skillId),
|
||
|
|
)
|
||
|
|
} else {
|
||
|
|
updateFormField('skills', [
|
||
|
|
...formData.skills,
|
||
|
|
{
|
||
|
|
skill_id: skillId,
|
||
|
|
is_primary: false,
|
||
|
|
intensity: '',
|
||
|
|
required_level: '',
|
||
|
|
target_level: '',
|
||
|
|
},
|
||
|
|
])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const updateSkillField = (skillId, field, value) => {
|
||
|
|
updateFormField(
|
||
|
|
'skills',
|
||
|
|
formData.skills.map((s) => (s.skill_id === skillId ? { ...s, [field]: value } : s)),
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleSubmit = async (e) => {
|
||
|
|
e.preventDefault()
|
||
|
|
if (!formData.title || formData.title.trim().length < 3) {
|
||
|
|
alert('Titel mindestens 3 Zeichen')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
let payload
|
||
|
|
try {
|
||
|
|
payload = buildExerciseApiPayload(formData)
|
||
|
|
} catch (err) {
|
||
|
|
alert(err.message)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
setSaving(true)
|
||
|
|
try {
|
||
|
|
if (isEdit) {
|
||
|
|
await api.updateExercise(exerciseId, payload)
|
||
|
|
const ex = await api.getExercise(exerciseId)
|
||
|
|
setMediaList(ex.media || [])
|
||
|
|
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 handleUploadFile = async () => {
|
||
|
|
if (!exerciseId || !mediaFile) {
|
||
|
|
alert('Datei wählen')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
const fd = new FormData()
|
||
|
|
fd.append('file', mediaFile)
|
||
|
|
fd.append('media_type', mediaType)
|
||
|
|
fd.append('title', mediaTitle)
|
||
|
|
fd.append('description', '')
|
||
|
|
fd.append('context', mediaContext)
|
||
|
|
fd.append('is_primary', 'false')
|
||
|
|
try {
|
||
|
|
await api.uploadExerciseMedia(exerciseId, fd)
|
||
|
|
setMediaFile(null)
|
||
|
|
setMediaTitle('')
|
||
|
|
await refreshMedia()
|
||
|
|
} catch (err) {
|
||
|
|
alert('Upload: ' + err.message)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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('Medium löschen?')) return
|
||
|
|
try {
|
||
|
|
await api.deleteExerciseMedia(exerciseId, mid)
|
||
|
|
await refreshMedia()
|
||
|
|
} catch (err) {
|
||
|
|
alert(err.message)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (loading) {
|
||
|
|
return (
|
||
|
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||
|
|
<div className="spinner"></div>
|
||
|
|
<p>Laden...</p>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div style={{ padding: '2rem' }}>
|
||
|
|
<div style={{ maxWidth: '720px', margin: '0 auto' }}>
|
||
|
|
<div style={{ marginBottom: '1rem' }}>
|
||
|
|
<button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}>
|
||
|
|
← Übersicht
|
||
|
|
</button>
|
||
|
|
{isEdit && (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="btn btn-secondary"
|
||
|
|
style={{ marginLeft: '0.5rem' }}
|
||
|
|
onClick={() => navigate(`/exercises/${exerciseId}`)}
|
||
|
|
>
|
||
|
|
Ansehen
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="card">
|
||
|
|
<h1 style={{ marginTop: 0 }}>{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>
|
||
|
|
<textarea
|
||
|
|
className="form-input"
|
||
|
|
rows={2}
|
||
|
|
value={formData.summary}
|
||
|
|
onChange={(e) => updateFormField('summary', e.target.value)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="form-row">
|
||
|
|
<label className="form-label">Ziel *</label>
|
||
|
|
<textarea
|
||
|
|
className="form-input"
|
||
|
|
rows={3}
|
||
|
|
value={formData.goal}
|
||
|
|
onChange={(e) => updateFormField('goal', e.target.value)}
|
||
|
|
placeholder="Mindestens Ziel oder Durchführung ausfüllen (Wiki-Import oft nur eines von beiden)."
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="form-row">
|
||
|
|
<label className="form-label">Durchführung *</label>
|
||
|
|
<textarea
|
||
|
|
className="form-input"
|
||
|
|
rows={4}
|
||
|
|
value={formData.execution}
|
||
|
|
onChange={(e) => updateFormField('execution', e.target.value)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="form-row">
|
||
|
|
<label className="form-label">Vorbereitung / Aufbau</label>
|
||
|
|
<textarea
|
||
|
|
className="form-input"
|
||
|
|
rows={3}
|
||
|
|
value={formData.preparation}
|
||
|
|
onChange={(e) => updateFormField('preparation', e.target.value)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="form-row">
|
||
|
|
<label className="form-label">Hinweise für Trainer</label>
|
||
|
|
<textarea
|
||
|
|
className="form-input"
|
||
|
|
rows={2}
|
||
|
|
value={formData.trainer_notes}
|
||
|
|
onChange={(e) => updateFormField('trainer_notes', e.target.value)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||
|
|
<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: '1rem' }}>
|
||
|
|
<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>
|
||
|
|
|
||
|
|
<div className="form-row">
|
||
|
|
<label className="form-label">Fokusbereich (primär)</label>
|
||
|
|
<select
|
||
|
|
className="form-input"
|
||
|
|
value={formData.focus_area_id || ''}
|
||
|
|
onChange={(e) =>
|
||
|
|
updateFormField('focus_area_id', e.target.value ? parseInt(e.target.value, 10) : null)
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<option value="">—</option>
|
||
|
|
{focusAreas.map((fa) => (
|
||
|
|
<option key={fa.id} value={fa.id}>
|
||
|
|
{fa.icon} {fa.name}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="form-row">
|
||
|
|
<label className="form-label">Stilrichtung (primär)</label>
|
||
|
|
<select
|
||
|
|
className="form-input"
|
||
|
|
value={formData.training_style_id || ''}
|
||
|
|
onChange={(e) =>
|
||
|
|
updateFormField(
|
||
|
|
'training_style_id',
|
||
|
|
e.target.value ? parseInt(e.target.value, 10) : null,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<option value="">—</option>
|
||
|
|
{trainingStyles.map((ts) => (
|
||
|
|
<option key={ts.id} value={ts.id}>
|
||
|
|
{ts.name}
|
||
|
|
{ts.parent_style_name ? ` (${ts.parent_style_name})` : ''}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||
|
|
<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>
|
||
|
|
<option value="official">Offiziell</option>
|
||
|
|
</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 className="form-row">
|
||
|
|
<label className="form-label">Fähigkeiten</label>
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
maxHeight: '220px',
|
||
|
|
overflowY: 'auto',
|
||
|
|
border: '1px solid var(--border)',
|
||
|
|
borderRadius: '8px',
|
||
|
|
padding: '0.5rem',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{skillsCatalog.map((skill) => {
|
||
|
|
const sel = formData.skills.find((s) => s.skill_id === skill.id)
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
key={skill.id}
|
||
|
|
style={{
|
||
|
|
padding: '0.5rem',
|
||
|
|
borderBottom: '1px solid var(--border)',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
checked={!!sel}
|
||
|
|
onChange={() => toggleSkill(skill.id)}
|
||
|
|
/>
|
||
|
|
<span style={{ fontSize: '0.875rem' }}>
|
||
|
|
{skill.name}{' '}
|
||
|
|
<span style={{ color: 'var(--text2)' }}>({skill.category})</span>
|
||
|
|
</span>
|
||
|
|
</label>
|
||
|
|
{sel && (
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
display: 'grid',
|
||
|
|
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
||
|
|
gap: '0.5rem',
|
||
|
|
marginTop: '0.5rem',
|
||
|
|
marginLeft: '1.5rem',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<select
|
||
|
|
className="form-input"
|
||
|
|
value={sel.intensity || ''}
|
||
|
|
onChange={(e) => updateSkillField(skill.id, 'intensity', e.target.value)}
|
||
|
|
>
|
||
|
|
{INTENSITY_OPTIONS.map((o) => (
|
||
|
|
<option key={o.value || 'x'} value={o.value}>
|
||
|
|
{o.label}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
<select
|
||
|
|
className="form-input"
|
||
|
|
value={sel.required_level || ''}
|
||
|
|
onChange={(e) =>
|
||
|
|
updateSkillField(skill.id, 'required_level', e.target.value)
|
||
|
|
}
|
||
|
|
>
|
||
|
|
{LEVEL_OPTIONS.map((o) => (
|
||
|
|
<option key={o.value || 'r'} value={o.value}>
|
||
|
|
von {o.label}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
<select
|
||
|
|
className="form-input"
|
||
|
|
value={sel.target_level || ''}
|
||
|
|
onChange={(e) =>
|
||
|
|
updateSkillField(skill.id, 'target_level', e.target.value)
|
||
|
|
}
|
||
|
|
>
|
||
|
|
{LEVEL_OPTIONS.map((o) => (
|
||
|
|
<option key={o.value || 't'} value={o.value}>
|
||
|
|
bis {o.label}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div style={{ marginTop: '1.5rem' }}>
|
||
|
|
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||
|
|
{saving ? 'Speichern…' : isEdit ? 'Speichern' : 'Anlegen & weiter'}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{isEdit && (
|
||
|
|
<div className="card" style={{ marginTop: '1.5rem' }}>
|
||
|
|
<h2 style={{ marginTop: 0 }}>Medien</h2>
|
||
|
|
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>
|
||
|
|
Datei (JPEG/PNG/GIF/MP4/PDF) oder Embed-URL (YouTube, Vimeo, Instagram, TikTok). Max. 10
|
||
|
|
Medien pro Übung.
|
||
|
|
</p>
|
||
|
|
|
||
|
|
<div style={{ display: 'grid', gap: '1rem', marginTop: '1rem' }}>
|
||
|
|
<div>
|
||
|
|
<label className="form-label">Datei hochladen</label>
|
||
|
|
<input
|
||
|
|
type="file"
|
||
|
|
accept="image/jpeg,image/png,image/gif,video/mp4,application/pdf"
|
||
|
|
onChange={(e) => setMediaFile(e.target.files?.[0] || null)}
|
||
|
|
/>
|
||
|
|
<div className="form-row" style={{ marginTop: '0.5rem' }}>
|
||
|
|
<select
|
||
|
|
className="form-input"
|
||
|
|
value={mediaType}
|
||
|
|
onChange={(e) => setMediaType(e.target.value)}
|
||
|
|
>
|
||
|
|
<option value="image">Bild</option>
|
||
|
|
<option value="video">Video</option>
|
||
|
|
<option value="document">Dokument (PDF)</option>
|
||
|
|
</select>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
className="form-input"
|
||
|
|
placeholder="Titel (optional)"
|
||
|
|
value={mediaTitle}
|
||
|
|
onChange={(e) => setMediaTitle(e.target.value)}
|
||
|
|
style={{ marginTop: '0.5rem' }}
|
||
|
|
/>
|
||
|
|
<select
|
||
|
|
className="form-input"
|
||
|
|
value={mediaContext}
|
||
|
|
onChange={(e) => setMediaContext(e.target.value)}
|
||
|
|
style={{ marginTop: '0.5rem' }}
|
||
|
|
>
|
||
|
|
<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" onClick={handleUploadFile}>
|
||
|
|
Hochladen
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<label className="form-label">Video / 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: '0.5rem' }}
|
||
|
|
/>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="btn btn-secondary"
|
||
|
|
style={{ marginTop: '0.5rem' }}
|
||
|
|
onClick={handleAddEmbed}
|
||
|
|
>
|
||
|
|
Embed hinzufügen
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{mediaList.length > 0 && (
|
||
|
|
<ul style={{ marginTop: '1rem', paddingLeft: '1.25rem' }}>
|
||
|
|
{mediaList.map((m) => (
|
||
|
|
<li key={m.id} style={{ marginBottom: '0.5rem' }}>
|
||
|
|
{m.title || m.original_filename || m.media_type}{' '}
|
||
|
|
{m.embed_platform ? `(${m.embed_platform})` : ''}
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="btn"
|
||
|
|
style={{
|
||
|
|
marginLeft: '0.5rem',
|
||
|
|
fontSize: '0.75rem',
|
||
|
|
padding: '0.2rem 0.5rem',
|
||
|
|
background: 'var(--danger)',
|
||
|
|
color: '#fff',
|
||
|
|
border: 'none',
|
||
|
|
}}
|
||
|
|
onClick={() => handleDeleteMedia(m.id)}
|
||
|
|
>
|
||
|
|
Löschen
|
||
|
|
</button>
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
export default ExerciseFormPage
|