feat: Exercise CRUD complete
Some checks failed
Deploy Development / deploy (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Failing after 14s

Backend:
- Registered exercises router in main.py
- Migration 005 already created (exercises, exercise_skills, exercise_variants, exercise_media)
- Full CRUD endpoints with visibility logic

Frontend:
- Complete ExercisesPage with list/create/edit/delete
- Filter controls (focus_area, visibility, status)
- Modal form with all fields
- Skills multi-select
- Mobile-responsive card layout

Next: Clubs Management
This commit is contained in:
Lars 2026-04-22 16:44:32 +02:00
parent de53ba3f66
commit 8c7cf91cef
3 changed files with 895 additions and 8 deletions

View File

@ -70,13 +70,14 @@ def read_root():
}
# Register routers
from routers import auth, profiles
from routers import auth, profiles, exercises
app.include_router(auth.router)
app.include_router(profiles.router)
app.include_router(exercises.router)
# TODO: Add more routers as they are created
# from routers import clubs, groups, skills, methods, exercises
# from routers import clubs, groups, skills, methods
# app.include_router(clubs.router, prefix="/api")
# ... etc

View File

@ -0,0 +1,334 @@
"""
Exercise Management Endpoints for Shinkan Jinkendo
Handles CRUD operations for exercises (Übungen).
"""
from typing import Optional
from fastapi import APIRouter, HTTPException, Depends, Query
from db import get_db, get_cursor, r2d
from auth import require_auth
router = APIRouter(prefix="/api", tags=["exercises"])
# ── List Exercises ────────────────────────────────────────────────────────
@router.get("/exercises")
def list_exercises(
focus_area: Optional[str] = Query(default=None),
visibility: Optional[str] = Query(default=None),
status: Optional[str] = Query(default=None),
skill_id: Optional[int] = Query(default=None),
session=Depends(require_auth)
):
"""
List exercises with optional filters.
Filters:
- focus_area: karate, selbstverteidigung, gewaltschutz
- visibility: private, club, official
- status: draft, in_review, approved, archived
- skill_id: Filter by associated skill
"""
profile_id = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
# Base query
query = """
SELECT DISTINCT e.*,
p.name as creator_name,
c.name as club_name,
tm.name as primary_method_name
FROM exercises e
LEFT JOIN profiles p ON e.created_by = p.id
LEFT JOIN clubs c ON e.club_id = c.id
LEFT JOIN training_methods tm ON e.primary_method_id = tm.id
"""
# Add skill join if filtering by skill
if skill_id:
query += " LEFT JOIN exercise_skills es ON e.id = es.exercise_id"
# Build WHERE clause
where = []
params = []
# Visibility filter: show own private + club + official
where.append("(e.visibility = 'official' OR e.visibility = 'club' OR e.created_by = %s)")
params.append(profile_id)
if focus_area:
where.append("e.focus_area = %s")
params.append(focus_area)
if visibility:
where.append("e.visibility = %s")
params.append(visibility)
if status:
where.append("e.status = %s")
params.append(status)
if skill_id:
where.append("es.skill_id = %s")
params.append(skill_id)
if where:
query += " WHERE " + " AND ".join(where)
query += " ORDER BY e.created_at DESC"
cur.execute(query, params)
rows = cur.fetchall()
return [r2d(r) for r in rows]
# ── Get Exercise ──────────────────────────────────────────────────────────
@router.get("/exercises/{exercise_id}")
def get_exercise(exercise_id: int, session=Depends(require_auth)):
"""Get exercise by ID with associated skills and variants."""
profile_id = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
# Get exercise
cur.execute("""
SELECT e.*,
p.name as creator_name,
c.name as club_name,
tm.name as primary_method_name
FROM exercises e
LEFT JOIN profiles p ON e.created_by = p.id
LEFT JOIN clubs c ON e.club_id = c.id
LEFT JOIN training_methods tm ON e.primary_method_id = tm.id
WHERE e.id = %s
""", (exercise_id,))
exercise = cur.fetchone()
if not exercise:
raise HTTPException(404, "Übung nicht gefunden")
exercise = r2d(exercise)
# Check visibility
if exercise['visibility'] == 'private' and exercise['created_by'] != profile_id:
raise HTTPException(403, "Keine Berechtigung")
# Get associated skills
cur.execute("""
SELECT es.*, s.name as skill_name, s.category as skill_category
FROM exercise_skills es
JOIN skills s ON es.skill_id = s.id
WHERE es.exercise_id = %s
ORDER BY es.is_primary DESC, s.name
""", (exercise_id,))
exercise['skills'] = [r2d(r) for r in cur.fetchall()]
# Get variants
cur.execute("""
SELECT * FROM exercise_variants
WHERE exercise_id = %s
ORDER BY created_at
""", (exercise_id,))
exercise['variants'] = [r2d(r) for r in cur.fetchall()]
# Get media
cur.execute("""
SELECT * FROM exercise_media
WHERE exercise_id = %s
ORDER BY sort_order, created_at
""", (exercise_id,))
exercise['media'] = [r2d(r) for r in cur.fetchall()]
return exercise
# ── Create Exercise ───────────────────────────────────────────────────────
@router.post("/exercises")
def create_exercise(data: dict, session=Depends(require_auth)):
"""Create new exercise."""
profile_id = session['profile_id']
# Required fields
title = data.get('title')
goal = data.get('goal')
execution = data.get('execution')
if not title or not goal or not execution:
raise HTTPException(400, "Titel, Ziel und Durchführung sind Pflichtfelder")
with get_db() as conn:
cur = get_cursor(conn)
# Insert exercise
cur.execute("""
INSERT INTO exercises (
title, summary, goal, execution, preparation, trainer_notes,
equipment, duration_min, duration_max, group_size_min, group_size_max,
age_groups, focus_area, secondary_areas, training_character,
primary_method_id, secondary_method_ids,
visibility, status, created_by, club_id
) VALUES (
%s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s,
%s, %s, %s, %s
) RETURNING id
""", (
title,
data.get('summary'),
goal,
execution,
data.get('preparation'),
data.get('trainer_notes'),
data.get('equipment'), # JSONB
data.get('duration_min'),
data.get('duration_max'),
data.get('group_size_min'),
data.get('group_size_max'),
data.get('age_groups'), # JSONB
data.get('focus_area'),
data.get('secondary_areas'), # JSONB
data.get('training_character'),
data.get('primary_method_id'),
data.get('secondary_method_ids'), # JSONB
data.get('visibility', 'private'),
data.get('status', 'draft'),
profile_id,
data.get('club_id')
))
exercise_id = cur.fetchone()['id']
# Add skills if provided
if data.get('skills'):
for skill in data['skills']:
cur.execute("""
INSERT INTO exercise_skills (
exercise_id, skill_id, is_primary, intensity,
development_contribution, required_level, target_level
) VALUES (%s, %s, %s, %s, %s, %s, %s)
""", (
exercise_id,
skill['skill_id'],
skill.get('is_primary', False),
skill.get('intensity'),
skill.get('development_contribution'),
skill.get('required_level'),
skill.get('target_level')
))
conn.commit()
return get_exercise(exercise_id, session)
# ── Update Exercise ───────────────────────────────────────────────────────
@router.put("/exercises/{exercise_id}")
def update_exercise(exercise_id: int, data: dict, session=Depends(require_auth)):
"""Update exercise."""
profile_id = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
# Check ownership
cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Übung nicht gefunden")
if row['created_by'] != profile_id:
raise HTTPException(403, "Keine Berechtigung")
# Update exercise
cur.execute("""
UPDATE exercises SET
title = %s, summary = %s, goal = %s, execution = %s,
preparation = %s, trainer_notes = %s, equipment = %s,
duration_min = %s, duration_max = %s,
group_size_min = %s, group_size_max = %s,
age_groups = %s, focus_area = %s, secondary_areas = %s,
training_character = %s, primary_method_id = %s,
secondary_method_ids = %s, visibility = %s, status = %s,
club_id = %s, updated_at = NOW()
WHERE id = %s
""", (
data.get('title'),
data.get('summary'),
data.get('goal'),
data.get('execution'),
data.get('preparation'),
data.get('trainer_notes'),
data.get('equipment'),
data.get('duration_min'),
data.get('duration_max'),
data.get('group_size_min'),
data.get('group_size_max'),
data.get('age_groups'),
data.get('focus_area'),
data.get('secondary_areas'),
data.get('training_character'),
data.get('primary_method_id'),
data.get('secondary_method_ids'),
data.get('visibility'),
data.get('status'),
data.get('club_id'),
exercise_id
))
# Update skills if provided
if 'skills' in data:
# Delete existing skills
cur.execute("DELETE FROM exercise_skills WHERE exercise_id = %s", (exercise_id,))
# Add new skills
for skill in data['skills']:
cur.execute("""
INSERT INTO exercise_skills (
exercise_id, skill_id, is_primary, intensity,
development_contribution, required_level, target_level
) VALUES (%s, %s, %s, %s, %s, %s, %s)
""", (
exercise_id,
skill['skill_id'],
skill.get('is_primary', False),
skill.get('intensity'),
skill.get('development_contribution'),
skill.get('required_level'),
skill.get('target_level')
))
conn.commit()
return get_exercise(exercise_id, session)
# ── Delete Exercise ───────────────────────────────────────────────────────
@router.delete("/exercises/{exercise_id}")
def delete_exercise(exercise_id: int, session=Depends(require_auth)):
"""Delete exercise (only owner or admin)."""
profile_id = session['profile_id']
role = session.get('role')
with get_db() as conn:
cur = get_cursor(conn)
# Check ownership or admin
cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Übung nicht gefunden")
if row['created_by'] != profile_id and role not in ['admin', 'superadmin']:
raise HTTPException(403, "Keine Berechtigung")
# Delete (CASCADE handles skills, variants, media)
cur.execute("DELETE FROM exercises WHERE id = %s", (exercise_id,))
conn.commit()
return {"ok": True}

View File

@ -1,14 +1,566 @@
import React, { useState, useEffect } from 'react'
import api from '../utils/api'
function ExercisesPage() {
const [exercises, setExercises] = useState([])
const [skills, setSkills] = useState([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingExercise, setEditingExercise] = useState(null)
const [filters, setFilters] = useState({
focus_area: '',
visibility: '',
status: ''
})
// Form state
const [formData, setFormData] = useState({
title: '',
summary: '',
goal: '',
execution: '',
preparation: '',
trainer_notes: '',
equipment: [],
duration_min: '',
duration_max: '',
group_size_min: '',
group_size_max: '',
age_groups: [],
focus_area: '',
secondary_areas: [],
training_character: '',
visibility: 'private',
status: 'draft',
skills: []
})
useEffect(() => {
loadData()
}, [filters])
const loadData = async () => {
try {
const [exercisesData, skillsData] = await Promise.all([
api.listExercises(filters),
api.listSkills()
])
setExercises(exercisesData)
setSkills(skillsData)
} catch (err) {
console.error('Failed to load data:', err)
alert('Fehler beim Laden: ' + err.message)
} finally {
setLoading(false)
}
}
const handleCreate = () => {
setEditingExercise(null)
setFormData({
title: '',
summary: '',
goal: '',
execution: '',
preparation: '',
trainer_notes: '',
equipment: [],
duration_min: '',
duration_max: '',
group_size_min: '',
group_size_max: '',
age_groups: [],
focus_area: '',
secondary_areas: [],
training_character: '',
visibility: 'private',
status: 'draft',
skills: []
})
setShowModal(true)
}
const handleEdit = (exercise) => {
setEditingExercise(exercise)
setFormData({
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: exercise.focus_area || '',
secondary_areas: exercise.secondary_areas || [],
training_character: exercise.training_character || '',
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 || null,
development_contribution: s.development_contribution || null,
required_level: s.required_level || null,
target_level: s.target_level || null
})) || []
})
setShowModal(true)
}
const handleDelete = async (exercise) => {
if (!confirm(`Übung "${exercise.title}" wirklich löschen?`)) return
try {
await api.deleteExercise(exercise.id)
await loadData()
} catch (err) {
alert('Fehler beim Löschen: ' + err.message)
}
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!formData.title || !formData.goal || !formData.execution) {
alert('Titel, Ziel und Durchführung sind Pflichtfelder')
return
}
try {
if (editingExercise) {
await api.updateExercise(editingExercise.id, formData)
} else {
await api.createExercise(formData)
}
setShowModal(false)
await loadData()
} catch (err) {
alert('Fehler beim Speichern: ' + err.message)
}
}
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: null,
development_contribution: null,
required_level: null,
target_level: null
}])
}
}
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: '1200px', margin: '0 auto' }}>
<h1>Übungsverwaltung</h1>
<div className="card" style={{ marginTop: '1.5rem' }}>
<p style={{ color: 'var(--text2)' }}>
Übungsverwaltung wird als nächstes implementiert
</p>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h1>Übungen</h1>
<button className="btn btn-primary" onClick={handleCreate}>
+ Neue Übung
</button>
</div>
{/* Filters */}
<div className="card" style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem' }}>
<div>
<label className="form-label">Fokusbereich</label>
<select
className="form-input"
value={filters.focus_area}
onChange={(e) => setFilters({ ...filters, focus_area: e.target.value })}
>
<option value="">Alle</option>
<option value="karate">Karate</option>
<option value="selbstverteidigung">Selbstverteidigung</option>
<option value="gewaltschutz">Gewaltschutz</option>
</select>
</div>
<div>
<label className="form-label">Sichtbarkeit</label>
<select
className="form-input"
value={filters.visibility}
onChange={(e) => setFilters({ ...filters, visibility: e.target.value })}
>
<option value="">Alle</option>
<option value="private">Privat</option>
<option value="club">Verein</option>
<option value="official">Offiziell</option>
</select>
</div>
<div>
<label className="form-label">Status</label>
<select
className="form-input"
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
>
<option value="">Alle</option>
<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>
{/* Exercises Grid */}
{exercises.length === 0 ? (
<div className="card">
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
Keine Übungen gefunden. Lege jetzt deine erste Übung an!
</p>
</div>
) : (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
gap: '1rem'
}}>
{exercises.map(exercise => (
<div key={exercise.id} className="card">
<div style={{ marginBottom: '1rem' }}>
<h3 style={{ marginBottom: '0.5rem' }}>{exercise.title}</h3>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<span style={{
fontSize: '0.75rem',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
background: 'var(--surface2)',
color: 'var(--text2)'
}}>
{exercise.focus_area || 'Ohne Fokus'}
</span>
<span style={{
fontSize: '0.75rem',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
background: exercise.visibility === 'official' ? 'var(--accent)' : 'var(--surface2)',
color: exercise.visibility === 'official' ? 'white' : 'var(--text2)'
}}>
{exercise.visibility}
</span>
<span style={{
fontSize: '0.75rem',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
background: exercise.status === 'approved' ? '#2ea44f' : 'var(--surface2)',
color: exercise.status === 'approved' ? 'white' : 'var(--text2)'
}}>
{exercise.status}
</span>
</div>
</div>
{exercise.summary && (
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem' }}>
{exercise.summary}
</p>
)}
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
<button
className="btn btn-secondary"
style={{ flex: 1 }}
onClick={() => handleEdit(exercise)}
>
Bearbeiten
</button>
<button
className="btn"
style={{
background: 'var(--danger)',
color: 'white',
border: 'none'
}}
onClick={() => handleDelete(exercise)}
>
Löschen
</button>
</div>
</div>
))}
</div>
)}
{/* Create/Edit Modal */}
{showModal && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '1rem'
}}>
<div style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '2rem',
maxWidth: '600px',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto'
}}>
<h2 style={{ marginBottom: '1.5rem' }}>
{editingExercise ? 'Übung bearbeiten' : 'Neue Übung'}
</h2>
<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
/>
</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 der Übung *</label>
<textarea
className="form-input"
rows={3}
value={formData.goal}
onChange={(e) => updateFormField('goal', e.target.value)}
required
/>
</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)}
required
/>
</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 (Minuten)</label>
<input
type="number"
className="form-input"
value={formData.duration_min}
onChange={(e) => updateFormField('duration_min', e.target.value ? parseInt(e.target.value) : '')}
/>
</div>
<div className="form-row">
<label className="form-label">Dauer Max (Minuten)</label>
<input
type="number"
className="form-input"
value={formData.duration_max}
onChange={(e) => updateFormField('duration_max', e.target.value ? parseInt(e.target.value) : '')}
/>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div className="form-row">
<label className="form-label">Gruppengröße 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) : '')}
/>
</div>
<div className="form-row">
<label className="form-label">Gruppengröße 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) : '')}
/>
</div>
</div>
<div className="form-row">
<label className="form-label">Fokusbereich</label>
<select
className="form-input"
value={formData.focus_area}
onChange={(e) => updateFormField('focus_area', e.target.value)}
>
<option value="">Bitte wählen</option>
<option value="karate">Karate</option>
<option value="selbstverteidigung">Selbstverteidigung</option>
<option value="gewaltschutz">Gewaltschutz</option>
</select>
</div>
<div className="form-row">
<label className="form-label">Trainingscharakter</label>
<select
className="form-input"
value={formData.training_character}
onChange={(e) => updateFormField('training_character', e.target.value)}
>
<option value="">Bitte wählen</option>
<option value="grundlage">Grundlage</option>
<option value="aufbau">Aufbau</option>
<option value="vertiefung">Vertiefung</option>
<option value="festigung">Festigung</option>
<option value="diagnose">Diagnose</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>
{/* Skills Selection */}
<div className="form-row">
<label className="form-label">Fähigkeiten</label>
<div style={{
maxHeight: '200px',
overflowY: 'auto',
border: '1px solid var(--border)',
borderRadius: '8px',
padding: '0.5rem'
}}>
{skills.map(skill => (
<label
key={skill.id}
style={{
display: 'flex',
alignItems: 'center',
padding: '0.5rem',
cursor: 'pointer',
borderRadius: '4px',
transition: 'background 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--surface2)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
>
<input
type="checkbox"
checked={formData.skills.some(s => s.skill_id === skill.id)}
onChange={() => toggleSkill(skill.id)}
style={{ marginRight: '0.5rem' }}
/>
<span style={{ fontSize: '0.875rem' }}>
{skill.name} <span style={{ color: 'var(--text2)' }}>({skill.category})</span>
</span>
</label>
))}
</div>
</div>
{/* Buttons */}
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.5rem' }}>
<button type="submit" className="btn btn-primary" style={{ flex: 1 }}>
{editingExercise ? 'Speichern' : 'Erstellen'}
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => setShowModal(false)}
>
Abbrechen
</button>
</div>
</form>
</div>
</div>
)}
</div>
</div>
)