shinkan-jinkendo/frontend/src/pages/ExerciseFormPage.jsx

822 lines
28 KiB
React
Raw Normal View History

import React, { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import api, { buildExerciseApiPayload } from '../utils/api'
import RichTextEditor from '../components/RichTextEditor'
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' },
]
const AGE_GROUP_OPTIONS = ['Minis', 'Kinder', 'Schüler', 'Teenager', 'Erwachsene']
function emptyForm() {
return {
title: '',
summary: '',
goal: '',
execution: '',
preparation: '',
trainer_notes: '',
equipmentLines: '',
duration_min: '',
duration_max: '',
group_size_min: '',
group_size_max: '',
age_groups: [],
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 ?? '',
age_groups: exercise.age_groups || [],
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: s.required_level || '',
target_level: 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 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 [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, 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)
}
}
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 toggleAgeGroup = (name) => {
const set = new Set(formData.age_groups)
if (set.has(name)) set.delete(name)
else set.add(name)
updateFormField('age_groups', [...set])
}
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) {
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)
}
}
const availableSkills = skillsCatalog.filter((s) => !formData.skills.some((x) => x.skill_id === s.id))
if (loading) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="spinner"></div>
<p>Laden...</p>
</div>
)
}
return (
<div style={{ padding: '12px', maxWidth: '720px', margin: '0 auto' }}>
<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"
/>
</div>
<div className="form-row">
<label className="form-label">Ziel *</label>
<RichTextEditor
value={formData.goal}
onChange={(html) => updateFormField('goal', html)}
placeholder="Trainingsziel"
minHeight="120px"
/>
</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"
/>
</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"
/>
</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"
/>
</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&#10;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>
<div className="form-row">
<label className="form-label">Altersgruppen (Katalog)</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{AGE_GROUP_OPTIONS.map((ag) => (
<label key={ag} style={{ fontSize: '14px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<input
type="checkbox"
checked={formData.age_groups.includes(ag)}
onChange={() => toggleAgeGroup(ag)}
/>
{ag}
</label>
))}
</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)}
>
{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)}
>
{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>
<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 style={{ marginTop: '16px' }}>
<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: '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: 'grid', gap: '12px', marginTop: '12px' }}>
<div>
<label className="form-label">Datei</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: '8px' }}>
<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">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: '1.2rem' }}>
{mediaList.map((m) => (
<li key={m.id} style={{ marginBottom: '6px' }}>
{m.title || m.original_filename || m.media_type}{' '}
{m.embed_platform ? `(${m.embed_platform})` : ''}
<button
type="button"
className="btn"
style={{
marginLeft: '8px',
fontSize: '11px',
padding: '2px 8px',
background: 'var(--danger)',
color: '#fff',
border: 'none',
}}
onClick={() => handleDeleteMedia(m.id)}
>
Löschen
</button>
</li>
))}
</ul>
)}
</div>
)}
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}>
Varianten-Editor folgt in einem späteren Schritt (API ist teilweise vorhanden).
</p>
</div>
)
}
export default ExerciseFormPage