feat: enhance exercise management and media handling
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 5s
Test Suite / playwright-tests (push) Failing after 1m54s

- Introduced new API endpoints for managing exercise media, including upload, update, delete, and reorder functionalities.
- Updated the exercise creation and update logic to ensure goal and execution fields are validated and normalized.
- Refactored frontend components to support the new exercise media features, including a dedicated import section for complete stack files.
- Removed the deprecated ExercisesPage component and replaced it with a more modular structure for exercise management.
- Incremented database schema version to 20260427028 and updated changelog to reflect these changes.
This commit is contained in:
Lars 2026-04-27 14:27:25 +02:00
parent 2452b5e2e8
commit cb11e39201
13 changed files with 1837 additions and 776 deletions

View File

@ -3,9 +3,12 @@ Shinkan Jinkendo - Main Application Entry Point
Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung
"""
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
import os
from version import APP_VERSION, BUILD_DATE, DB_SCHEMA_VERSION, MODULE_VERSIONS
@ -84,6 +87,11 @@ app.include_router(matrix_stack_bundle.router)
app.include_router(import_wiki.router)
app.include_router(import_wiki_admin.router)
# Lokale Medien (Übungen-Uploads) unter MEDIA_ROOT, ausliefern unter /media/...
_media_dir = os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent / "media"))
Path(_media_dir).mkdir(parents=True, exist_ok=True)
app.mount("/media", StaticFiles(directory=_media_dir), name="media")
if __name__ == "__main__":
import uvicorn
uvicorn.run(

View File

@ -0,0 +1,72 @@
-- Migration 028: exercise_media (Upload/Embed laut Spec) + exercise_skills Typen für API v2
-- Datum: 2026-04-27
-- exercise_media: Metadaten + Embed
ALTER TABLE exercise_media ADD COLUMN IF NOT EXISTS file_size INT;
ALTER TABLE exercise_media ADD COLUMN IF NOT EXISTS mime_type VARCHAR(100);
ALTER TABLE exercise_media ADD COLUMN IF NOT EXISTS original_filename VARCHAR(300);
ALTER TABLE exercise_media ADD COLUMN IF NOT EXISTS embed_url TEXT;
ALTER TABLE exercise_media ADD COLUMN IF NOT EXISTS embed_platform VARCHAR(50);
ALTER TABLE exercise_media ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT NOW();
CREATE INDEX IF NOT EXISTS idx_exercise_media_exercise_sort ON exercise_media(exercise_id, sort_order);
-- exercise_skills: KI-Flag + benannte Stufen / Intensität (EXERCISES_API_SPEC)
ALTER TABLE exercise_skills ADD COLUMN IF NOT EXISTS ai_suggested BOOLEAN DEFAULT FALSE;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns c
WHERE c.table_schema = 'public' AND c.table_name = 'exercise_skills'
AND c.column_name = 'intensity' AND c.data_type IN ('integer', 'bigint', 'smallint')
) THEN
ALTER TABLE exercise_skills DROP CONSTRAINT IF EXISTS exercise_skills_intensity_check;
ALTER TABLE exercise_skills ALTER COLUMN intensity DROP DEFAULT;
ALTER TABLE exercise_skills ALTER COLUMN intensity TYPE VARCHAR(10) USING
CASE
WHEN intensity IS NULL THEN NULL
WHEN intensity::integer <= 2 THEN 'niedrig'
WHEN intensity::integer = 3 THEN 'mittel'
ELSE 'hoch'
END;
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns c
WHERE c.table_schema = 'public' AND c.table_name = 'exercise_skills'
AND c.column_name = 'required_level' AND c.data_type IN ('integer', 'bigint', 'smallint')
) THEN
ALTER TABLE exercise_skills ALTER COLUMN required_level TYPE VARCHAR(20) USING
CASE WHEN required_level IS NULL THEN NULL ELSE required_level::text END;
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns c
WHERE c.table_schema = 'public' AND c.table_name = 'exercise_skills'
AND c.column_name = 'target_level' AND c.data_type IN ('integer', 'bigint', 'smallint')
) THEN
ALTER TABLE exercise_skills ALTER COLUMN target_level TYPE VARCHAR(20) USING
CASE WHEN target_level IS NULL THEN NULL ELSE target_level::text END;
END IF;
END $$;
-- Volltext: Ziel einbeziehen (Wiki-Import nutzt oft nur goal)
CREATE OR REPLACE FUNCTION update_exercises_search_vector()
RETURNS trigger AS $func$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('german', COALESCE(NEW.title, '')), 'A') ||
setweight(to_tsvector('german', COALESCE(NEW.summary, '')), 'B') ||
setweight(to_tsvector('german', COALESCE(NEW.goal, '')), 'B') ||
setweight(to_tsvector('german', COALESCE(NEW.execution, '')), 'C') ||
setweight(to_tsvector('german', COALESCE(NEW.trainer_notes, '')), 'D');
RETURN NEW;
END;
$func$ LANGUAGE plpgsql;

View File

@ -4,12 +4,15 @@ Exercises Router - v2.0 (Clean-Room Rebuild)
Komplett neu gebaut nach EXERCISES_API_SPEC.md v1.2
KEIN Legacy-Code aus v1 - nur M:N Relations, keine JSONB-Felder für Kataloge
"""
import hashlib
import json
import logging
import os
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException, Depends, Query
from pydantic import BaseModel, Field
from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File, Form
from pydantic import BaseModel, Field, model_validator
from db import get_db, get_cursor, r2d
from auth import require_auth
@ -18,17 +21,24 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["exercises"])
MEDIA_ROOT = Path(os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent.parent / "media")))
MAX_EXERCISE_MEDIA = 10
MAX_UPLOAD_BYTES = 50 * 1024 * 1024
ALLOWED_UPLOAD_MIMES = frozenset(
{"image/jpeg", "image/png", "image/gif", "video/mp4", "application/pdf"}
)
# ============================================================================
# Pydantic Models
# ============================================================================
class ExerciseCreate(BaseModel):
# Basis-Felder
# Basis-Felder (goal/execution: DB-Constraint mind. eines; Wiki oft nur eines)
title: str = Field(..., min_length=3, max_length=300)
summary: Optional[str] = None
goal: str = Field(..., min_length=10, max_length=5000)
execution: str = Field(..., min_length=10, max_length=10000)
goal: Optional[str] = Field(None, max_length=5000)
execution: Optional[str] = Field(None, max_length=10000)
preparation: Optional[str] = None
trainer_notes: Optional[str] = None
@ -55,13 +65,23 @@ class ExerciseCreate(BaseModel):
status: str = "draft"
club_id: Optional[int] = None
@model_validator(mode="after")
def normalize_goal_execution(self):
g = (self.goal or "").strip() or None
e = (self.execution or "").strip() or None
if not g and not e:
raise ValueError("Mindestens eines der Felder Ziel oder Durchführung ist erforderlich")
self.goal = g
self.execution = e
return self
class ExerciseUpdate(BaseModel):
# Alle Felder optional für Partial Update
title: Optional[str] = Field(None, min_length=3, max_length=300)
summary: Optional[str] = None
goal: Optional[str] = Field(None, min_length=10, max_length=5000)
execution: Optional[str] = Field(None, min_length=10, max_length=10000)
goal: Optional[str] = Field(None, max_length=5000)
execution: Optional[str] = Field(None, max_length=10000)
preparation: Optional[str] = None
trainer_notes: Optional[str] = None
duration_min: Optional[int] = None
@ -78,11 +98,87 @@ class ExerciseUpdate(BaseModel):
status: Optional[str] = None
club_id: Optional[int] = None
@model_validator(mode="after")
def normalize_goal_execution(self):
if self.goal is not None:
self.goal = self.goal.strip() or None
if self.execution is not None:
self.execution = self.execution.strip() or None
return self
class ExerciseMediaUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
is_primary: Optional[bool] = None
context: Optional[str] = None
class ExerciseMediaReorder(BaseModel):
media_ids: list[int]
# ============================================================================
# Helper Functions
# ============================================================================
def _row_created_by(row) -> int:
if row is None:
return None
if isinstance(row, dict):
return row.get("created_by")
return row[0]
def _ensure_media_dirs():
sub = MEDIA_ROOT / "exercises"
sub.mkdir(parents=True, exist_ok=True)
return sub
def _detect_embed_platform(url: str) -> Optional[str]:
if not url:
return None
u = url.lower()
if "youtube.com" in u or "youtu.be" in u:
return "youtube"
if "vimeo.com" in u:
return "vimeo"
if "instagram.com" in u:
return "instagram"
if "tiktok.com" in u:
return "tiktok"
return None
def _assert_can_edit_exercise(cur, exercise_id: int, profile_id: int):
cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,))
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
if _row_created_by(row) != profile_id:
raise HTTPException(status_code=403, detail="Nur der Ersteller darf diese Übung bearbeiten")
def _count_exercise_media(cur, exercise_id: int) -> int:
cur.execute("SELECT COUNT(*) AS c FROM exercise_media WHERE exercise_id = %s", (exercise_id,))
r = cur.fetchone()
return int(r["c"] if isinstance(r, dict) else r[0])
def _abs_media_path(file_path_db: str) -> Optional[Path]:
if not file_path_db or file_path_db.startswith("http"):
return None
rel = file_path_db.lstrip("/")
if rel.startswith("media/"):
rel = rel[len("media/") :]
p = MEDIA_ROOT / rel
try:
p.resolve().relative_to(MEDIA_ROOT.resolve())
except ValueError:
return None
return p
def enrich_exercise_detail(exercise_id: int, cur) -> dict:
"""
Lädt alle M:N Relations für eine Übung und gibt ein vollständiges
@ -314,12 +410,19 @@ def list_exercises(
where.append("e.search_vector @@ plainto_tsquery('german', %s)")
params.append(search)
# Query
# Query (primary_focus_name für Listen-Ansicht gemäß Spec-Beispiel „focus_area“-Label)
query = f"""
SELECT e.id, e.title, e.summary, e.visibility, e.status,
e.created_by, p.name as creator_name,
e.club_id, c.name as club_name,
e.created_at, e.updated_at
e.created_at, e.updated_at,
(
SELECT fa.name FROM exercise_focus_areas efa
JOIN focus_areas fa ON fa.id = efa.focus_area_id
WHERE efa.exercise_id = e.id
ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
LIMIT 1
) AS primary_focus_name
FROM exercises e
LEFT JOIN profiles p ON e.created_by = p.id
LEFT JOIN clubs c ON e.club_id = c.id
@ -332,7 +435,13 @@ def list_exercises(
cur.execute(query, params)
rows = cur.fetchall()
return [r2d(r) for r in rows]
out = []
for r in rows:
d = r2d(r)
pfn = d.get("primary_focus_name")
d["focus_area"] = pfn
out.append(d)
return out
@router.get("/exercises/{exercise_id}")
@ -434,7 +543,7 @@ def update_exercise(
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
# Permission Check
if row[0] != profile_id:
if _row_created_by(row) != profile_id:
raise HTTPException(status_code=403, detail="Nur der Ersteller darf editieren")
# UPDATE (nur gesetzte Felder)
@ -495,15 +604,16 @@ def delete_exercise(
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
# Permission Check
if row[0] != profile_id and role not in ("admin", "superadmin"):
if _row_created_by(row) != profile_id and role not in ("admin", "superadmin"):
raise HTTPException(status_code=403, detail="Nur Ersteller oder Admin darf löschen")
# Prüfen ob Übung in Trainingseinheiten verwendet wird
# Prüfen ob Übung in Block-Items verwendet wird
cur.execute(
"SELECT COUNT(*) FROM exercise_block_items WHERE exercise_id = %s",
"SELECT COUNT(*) AS cnt FROM exercise_block_items WHERE exercise_id = %s",
(exercise_id,)
)
count = cur.fetchone()[0]
crow = cur.fetchone()
count = crow["cnt"] if isinstance(crow, dict) else crow[0]
if count > 0:
raise HTTPException(
status_code=409,
@ -515,3 +625,244 @@ def delete_exercise(
conn.commit()
return {"ok": True}
# --- Medien (MEDIA_UPLOAD_SPEC.md / EXERCISES_API_SPEC.md) ---
def _fetch_media_row(cur, exercise_id: int, media_id: int) -> Optional[dict]:
cur.execute(
"""SELECT id, exercise_id, media_type, file_path, file_size, mime_type, original_filename,
embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at
FROM exercise_media WHERE id = %s AND exercise_id = %s""",
(media_id, exercise_id),
)
row = cur.fetchone()
return r2d(row) if row else None
@router.post("/exercises/{exercise_id}/media", status_code=201)
async def upload_exercise_media(
exercise_id: int,
session: dict = Depends(require_auth),
file: Optional[UploadFile] = File(None),
embed_url: Optional[str] = Form(None),
media_type: str = Form(...),
title: str = Form(""),
description: str = Form(""),
context: str = Form("ablauf"),
is_primary: bool = Form(False),
):
profile_id = session["profile_id"]
if media_type not in ("image", "video", "document", "sketch"):
raise HTTPException(status_code=400, detail="Ungültiger media_type")
if context not in ("ablauf", "detail", "trainer_hint"):
raise HTTPException(status_code=400, detail="Ungültiger context")
emb = (embed_url or "").strip() or None
has_file = file is not None and bool(file.filename)
with get_db() as conn:
cur = get_cursor(conn)
_assert_can_edit_exercise(cur, exercise_id, profile_id)
if _count_exercise_media(cur, exercise_id) >= MAX_EXERCISE_MEDIA:
raise HTTPException(
status_code=400,
detail=f"Maximal {MAX_EXERCISE_MEDIA} Medien pro Übung",
)
if has_file and emb:
raise HTTPException(status_code=400, detail="Entweder Datei oder embed_url, nicht beides")
if not has_file and not emb:
raise HTTPException(status_code=400, detail="Datei oder embed_url erforderlich")
sort_sql = (
"COALESCE((SELECT MAX(sort_order) + 1 FROM exercise_media WHERE exercise_id = %s), 1)"
)
if emb:
platform = _detect_embed_platform(emb)
if not platform:
raise HTTPException(
status_code=400,
detail="Ungültige Embed-URL (erlaubt: YouTube, Vimeo, Instagram, TikTok)",
)
cur.execute(
f"""INSERT INTO exercise_media (
exercise_id, media_type, file_path, file_size, mime_type, original_filename,
embed_url, embed_platform, title, description, context, is_primary, sort_order
) VALUES (
%s, %s, NULL, NULL, NULL, NULL, %s, %s, %s, %s, %s, %s, {sort_sql}
)
RETURNING id, exercise_id, media_type, file_path, file_size, mime_type, original_filename,
embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at""",
(
exercise_id,
media_type,
emb,
platform,
title or None,
description or None,
context,
is_primary,
exercise_id,
),
)
else:
raw = await file.read()
if len(raw) > MAX_UPLOAD_BYTES:
raise HTTPException(
status_code=413,
detail=f"Datei zu groß (max. {MAX_UPLOAD_BYTES // (1024 * 1024)} MB)",
)
mime = file.content_type or ""
if mime not in ALLOWED_UPLOAD_MIMES:
raise HTTPException(
status_code=400,
detail=f"Dateityp nicht erlaubt: {mime or 'unbekannt'}",
)
ext = Path(file.filename or "").suffix[:12] if file.filename else ""
if not ext and mime == "image/jpeg":
ext = ".jpg"
elif not ext and mime == "image/png":
ext = ".png"
digest = hashlib.sha256(raw).hexdigest()[:12]
fname = f"{digest}_{exercise_id}{ext}"
dest_dir = _ensure_media_dirs()
dest_path = dest_dir / fname
dest_path.write_bytes(raw)
db_path = f"/media/exercises/{fname}"
cur.execute(
f"""INSERT INTO exercise_media (
exercise_id, media_type, file_path, file_size, mime_type, original_filename,
embed_url, embed_platform, title, description, context, is_primary, sort_order
) VALUES (
%s, %s, %s, %s, %s, %s, NULL, NULL, %s, %s, %s, %s, {sort_sql}
)
RETURNING id, exercise_id, media_type, file_path, file_size, mime_type, original_filename,
embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at""",
(
exercise_id,
media_type,
db_path,
len(raw),
mime,
file.filename,
title or None,
description or None,
context,
is_primary,
exercise_id,
),
)
row = cur.fetchone()
conn.commit()
return r2d(row)
@router.put("/exercises/{exercise_id}/media/reorder")
def reorder_exercise_media(
exercise_id: int,
body: ExerciseMediaReorder,
session: dict = Depends(require_auth),
):
profile_id = session["profile_id"]
ids = body.media_ids
with get_db() as conn:
cur = get_cursor(conn)
_assert_can_edit_exercise(cur, exercise_id, profile_id)
cur.execute(
"SELECT id FROM exercise_media WHERE exercise_id = %s ORDER BY sort_order, id",
(exercise_id,),
)
existing = [r["id"] if isinstance(r, dict) else r[0] for r in cur.fetchall()]
if set(ids) != set(existing) or len(ids) != len(existing):
raise HTTPException(
status_code=400,
detail="media_ids unvollständig oder gehören nicht zu dieser Übung",
)
for i, mid in enumerate(ids, start=1):
cur.execute(
"UPDATE exercise_media SET sort_order = %s, updated_at = NOW() WHERE id = %s AND exercise_id = %s",
(i, mid, exercise_id),
)
conn.commit()
return {"ok": True, "reordered": len(ids)}
@router.put("/exercises/{exercise_id}/media/{media_id}")
def update_exercise_media(
exercise_id: int,
media_id: int,
body: ExerciseMediaUpdate,
session: dict = Depends(require_auth),
):
profile_id = session["profile_id"]
data = body.dict(exclude_unset=True)
if not data:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
with get_db() as conn:
cur = get_cursor(conn)
_assert_can_edit_exercise(cur, exercise_id, profile_id)
if not _fetch_media_row(cur, exercise_id, media_id):
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
if "context" in data and data["context"] not in ("ablauf", "detail", "trainer_hint", None):
raise HTTPException(status_code=400, detail="Ungültiger context")
fields = []
params = []
for k in ("title", "description", "is_primary", "context"):
if k in data:
fields.append(f"{k} = %s")
params.append(data[k])
if fields:
fields.append("updated_at = NOW()")
params.extend([media_id, exercise_id])
cur.execute(
f"UPDATE exercise_media SET {', '.join(fields)} WHERE id = %s AND exercise_id = %s",
params,
)
conn.commit()
cur.execute(
"""SELECT id, media_type, file_path, file_size, mime_type, original_filename,
embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at
FROM exercise_media WHERE id = %s""",
(media_id,),
)
return r2d(cur.fetchone())
@router.delete("/exercises/{exercise_id}/media/{media_id}")
def delete_exercise_media(
exercise_id: int,
media_id: int,
session: dict = Depends(require_auth),
):
profile_id = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
_assert_can_edit_exercise(cur, exercise_id, profile_id)
cur.execute(
"""SELECT file_path FROM exercise_media WHERE id = %s AND exercise_id = %s""",
(media_id, exercise_id),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
fp = row["file_path"] if isinstance(row, dict) else row[0]
cur.execute(
"DELETE FROM exercise_media WHERE id = %s AND exercise_id = %s",
(media_id, exercise_id),
)
conn.commit()
abs_p = _abs_media_path(fp) if fp else None
if abs_p and abs_p.is_file():
try:
abs_p.unlink()
except OSError as e:
logger.warning("Medien-Datei konnte nicht gelöscht werden: %s", e)
return {"ok": True}

View File

@ -12,7 +12,7 @@ import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
@ -336,7 +336,10 @@ def _upsert_skill_category(
return old_id, new_id
def import_matrix_stack_v1(data: Dict[str, Any], session: dict = Depends(require_auth)) -> Dict[str, Any]:
def import_matrix_stack_v1(
data: Dict[str, Any] = Body(...),
session: dict = Depends(require_auth),
) -> Dict[str, Any]:
_require_admin(session)
if data.get("kind") != KIND_V1:
raise HTTPException(400, f"kind muss {KIND_V1} sein")

View File

@ -2,7 +2,7 @@
APP_VERSION = "0.7.6"
BUILD_DATE = "2026-04-27"
DB_SCHEMA_VERSION = "20260427027"
DB_SCHEMA_VERSION = "20260427028"
MODULE_VERSIONS = {
"auth": "1.0.0",
@ -28,6 +28,7 @@ CHANGELOG = [
"date": "2026-04-27",
"changes": [
"API: GET/POST /api/admin/matrix-stack (shinkan.matrix_stack.v1) Fähigkeitskatalog, Reifegradmodelle, Kontext-Bindings für Test→Prod",
"DB 028: exercise_media (Embed/Metadaten), exercise_skills VARCHAR-Level/Intensität; API: POST/PUT/DELETE Medien, /media Static; Übungen-Listen-Fokus",
],
},
{

View File

@ -51,7 +51,7 @@ Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im
- **`frontend/src/pages/AdminMaturityModelsPage.jsx`**: Tabs u. a. Katalog, Modelle, Kontext-Zuordnung, **Matrix-Ansicht und Export**.
- **`MaturityModelBindingsAdmin.jsx`**: Bindings CRUD, Erklärung Merge/Legacy.
- **`MaturityMatrixToolsAdmin.jsx`**: Kontext auflösen, hierarchische Matrix-Ansicht, Export einzelnes Modell / aufgelöst, Import, **Komplett-Stack** Export/Import.
- **`MaturityMatrixToolsAdmin.jsx`**: Kontext auflösen, hierarchische Matrix-Ansicht, Export einzelnes Modell / aufgelöst, Einzelmodell-Import; **Komplett-Stack** mit eigenem Export-Button und **eigenem Dateifeld für Stack-Import** (`POST /api/admin/matrix-stack/import`).
- **`frontend/src/utils/api.js`**: u. a. `exportMatrixStackBundle`, `importMatrixStackBundle`, Reifegrad-APIs.
---

View File

@ -1,12 +1,22 @@
import React from 'react'
import { BrowserRouter as Router, Routes, Route, Navigate, NavLink, useLocation } from 'react-router-dom'
import {
BrowserRouter as Router,
Routes,
Route,
Navigate,
NavLink,
useLocation,
Outlet,
} from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import DesktopSidebar from './components/DesktopSidebar'
import { getMainNavItems } from './config/appNav'
import LoginPage from './pages/LoginPage'
import Dashboard from './pages/Dashboard'
import ProfilePage from './pages/ProfilePage'
import ExercisesPage from './pages/ExercisesPage'
import ExercisesListPage from './pages/ExercisesListPage'
import ExerciseDetailPage from './pages/ExerciseDetailPage'
import ExerciseFormPage from './pages/ExerciseFormPage'
import ClubsPage from './pages/ClubsPage'
import SkillsPage from './pages/SkillsPage'
import TrainingPlanningPage from './pages/TrainingPlanningPage'
@ -35,8 +45,7 @@ function Nav({ isAdmin }) {
to={item.to}
end={!!item.end}
className={({ isActive }) =>
'nav-item' +
(navItemActive(loc.pathname, item, isActive) ? ' active' : '')
'nav-item' + (navItemActive(loc.pathname, item, isActive) ? ' active' : '')
}
>
<item.Icon size={20} strokeWidth={2} />
@ -47,8 +56,7 @@ function Nav({ isAdmin }) {
)
}
// Protected Route Component
function ProtectedRoute({ children }) {
function ProtectedLayout() {
const { isAuthenticated, loading, user, logout } = useAuth()
const handleLogout = () => {
@ -60,13 +68,15 @@ function ProtectedRoute({ children }) {
if (loading) {
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--bg)'
}}>
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--bg)',
}}
>
<div className="spinner"></div>
</div>
)
@ -80,17 +90,15 @@ function ProtectedRoute({ children }) {
return (
<>
<DesktopSidebar
isAdmin={isAdmin}
user={user}
onLogout={handleLogout}
/>
<DesktopSidebar isAdmin={isAdmin} user={user} onLogout={handleLogout} />
<div className="app-shell">
<div className="app-shell__column">
<div className="app-header app-header--mobile">
<div className="app-logo">🥋 Shinkan</div>
</div>
<div className="app-main">{children}</div>
<div className="app-main">
<Outlet />
</div>
<Nav isAdmin={isAdmin} />
</div>
</div>
@ -98,19 +106,20 @@ function ProtectedRoute({ children }) {
)
}
// Public Route Component (redirect to dashboard if already logged in)
function PublicRoute({ children }) {
const { isAuthenticated, loading } = useAuth()
if (loading) {
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--bg)'
}}>
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--bg)',
}}
>
<div className="spinner"></div>
</div>
)
@ -122,7 +131,6 @@ function PublicRoute({ children }) {
function AppRoutes() {
return (
<Routes>
{/* Public Routes */}
<Route
path="/login"
element={
@ -132,101 +140,26 @@ function AppRoutes() {
}
/>
{/* Protected Routes */}
<Route
path="/"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/profile"
element={
<ProtectedRoute>
<ProfilePage />
</ProtectedRoute>
}
/>
<Route
path="/exercises"
element={
<ProtectedRoute>
<ExercisesPage />
</ProtectedRoute>
}
/>
<Route
path="/clubs"
element={
<ProtectedRoute>
<ClubsPage />
</ProtectedRoute>
}
/>
<Route
path="/skills"
element={
<ProtectedRoute>
<SkillsPage />
</ProtectedRoute>
}
/>
<Route
path="/planning"
element={
<ProtectedRoute>
<TrainingPlanningPage />
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={<Navigate to="/admin/hierarchy" replace />}
/>
<Route
path="/admin/hierarchy"
element={
<ProtectedRoute>
<AdminHierarchyPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/maturity-models"
element={
<ProtectedRoute>
<AdminMaturityModelsPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/catalogs"
element={
<ProtectedRoute>
<AdminCatalogsPage />
</ProtectedRoute>
}
/>
<Route
path="/admin/mediawiki-import"
element={
<ProtectedRoute>
<MediaWikiImportPage />
</ProtectedRoute>
}
/>
<Route
path="/trainer-contexts"
element={
<ProtectedRoute>
<TrainerContextsPage />
</ProtectedRoute>
}
/>
<Route element={<ProtectedLayout />}>
<Route index element={<Dashboard />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="exercises">
<Route index element={<ExercisesListPage />} />
<Route path="new" element={<ExerciseFormPage />} />
<Route path=":id/edit" element={<ExerciseFormPage />} />
<Route path=":id" element={<ExerciseDetailPage />} />
</Route>
<Route path="clubs" element={<ClubsPage />} />
<Route path="skills" element={<SkillsPage />} />
<Route path="planning" element={<TrainingPlanningPage />} />
<Route path="admin" element={<Navigate to="/admin/hierarchy" replace />} />
<Route path="admin/hierarchy" element={<AdminHierarchyPage />} />
<Route path="admin/maturity-models" element={<AdminMaturityModelsPage />} />
<Route path="admin/catalogs" element={<AdminCatalogsPage />} />
<Route path="admin/mediawiki-import" element={<MediaWikiImportPage />} />
<Route path="trainer-contexts" element={<TrainerContextsPage />} />
</Route>
{/* Catch all - redirect to dashboard or login */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)

View File

@ -55,6 +55,7 @@ export default function MaturityMatrixToolsAdmin() {
const [stackWipe, setStackWipe] = useState(false)
const [stackConfirmText, setStackConfirmText] = useState('')
const [stackLoading, setStackLoading] = useState(false)
const [stackImportLoading, setStackImportLoading] = useState(false)
useEffect(() => {
let cancelled = false
@ -159,6 +160,45 @@ export default function MaturityMatrixToolsAdmin() {
}
}
async function handleImportStackFile(e) {
const file = e.target.files?.[0]
if (!file) return
setError('')
setMessage('')
setStackImportLoading(true)
try {
const data = JSON.parse(await file.text())
if (data.kind !== 'shinkan.matrix_stack.v1') {
setError('Erwartet wird eine Datei mit kind: shinkan.matrix_stack.v1 (Komplett-Stack-Export).')
e.target.value = ''
return
}
if (stackWipe && stackConfirmText !== 'DELETE_MATURITY_STACK') {
setError('Vollständiges Ersetzen: Bestätigung exakt „DELETE_MATURITY_STACK“ eintragen (Superadmin).')
e.target.value = ''
return
}
const payload = {
...data,
replace_all_maturity_models: stackWipe,
confirm_replace_all: stackWipe ? stackConfirmText : undefined
}
const res = await api.importMatrixStackBundle(payload)
const w = res.warnings || []
setMessage(
`Stack-Import OK. Modell-Zuordnung: ${Object.keys(res.model_id_map || {}).length} Modell(e), Skills gemappt: ${Object.keys(res.skill_id_map || {}).length}.` +
(w.length ? ` ${w.length} Hinweis(e) (Konsole).` : '')
)
if (w.length) console.warn('matrix_stack import warnings', w)
setModels(await api.listMaturityModels({}))
} catch (err) {
setError(err.message || String(err))
} finally {
setStackImportLoading(false)
e.target.value = ''
}
}
async function handleImportFile(e) {
const file = e.target.files?.[0]
if (!file) return
@ -167,24 +207,9 @@ export default function MaturityMatrixToolsAdmin() {
try {
const data = JSON.parse(await file.text())
if (data.kind === 'shinkan.matrix_stack.v1') {
if (stackWipe && stackConfirmText !== 'DELETE_MATURITY_STACK') {
setError('Vollständiges Ersetzen: Bestätigung exakt „DELETE_MATURITY_STACK“ eintragen (Superadmin).')
e.target.value = ''
return
}
const payload = {
...data,
replace_all_maturity_models: stackWipe,
confirm_replace_all: stackWipe ? stackConfirmText : undefined
}
const res = await api.importMatrixStackBundle(payload)
const w = res.warnings || []
setMessage(
`Stack-Import OK. Modell-Zuordnung: ${Object.keys(res.model_id_map || {}).length} Modell(e).` +
(w.length ? ` ${w.length} Hinweis(e).` : '')
)
if (w.length) console.warn('matrix_stack import warnings', w)
setModels(await api.listMaturityModels({}))
setError('Komplett-Stack: bitte die Datei im Abschnitt „Komplett-Stack“ importieren (eigenes Dateifeld).')
e.target.value = ''
return
} else {
const payload = { ...data, mode: importMode, import_bindings: importBindings }
if (importMode === 'replace') {
@ -362,17 +387,17 @@ export default function MaturityMatrixToolsAdmin() {
<button
type="button"
className="btn btn-primary"
disabled={stackLoading}
disabled={stackLoading || stackImportLoading}
onClick={handleExportStack}
>
{stackLoading ? 'Export…' : 'Komplett-Stack exportieren'}
</button>
</div>
<h3 className="admin-matrix-tools__h3">Stack-Import (JSON-Datei unten)</h3>
<h3 className="admin-matrix-tools__h3">Komplett-Stack importieren</h3>
<p className="muted admin-matrix-tools__hint">
Datei mit <code className="admin-bindings__code">kind: shinkan.matrix_stack.v1</code>. Katalog wird per Slug
zusammengeführt; Skills per Kategorie + Name. Optional alle Reifegradmodelle auf der Ziel-DB vorher löschen
(nur Superadmin, Vorsicht).
JSON vom Export dieses Dialogs (<code className="admin-bindings__code">shinkan.matrix_stack.v1</code>). Katalog
wird per Slug zusammengeführt; Skills per Kategorie + Name. Reifegradmodelle werden neu angelegt; Kontext-Bindings
über Namen der Fokus-/Stil-/Trainingsstil-Kataloge auf der Ziel-DB.
</p>
<label className="form-label admin-matrix-tools__check">
<input
@ -397,6 +422,14 @@ export default function MaturityMatrixToolsAdmin() {
/>
</>
) : null}
<label className="form-label">Stack-JSON-Datei</label>
<input
type="file"
accept="application/json,.json"
disabled={stackImportLoading}
onChange={handleImportStackFile}
/>
{stackImportLoading ? <p className="muted admin-matrix-tools__msg">Import läuft</p> : null}
</section>
<section className="card admin-matrix-tools__section">
@ -431,10 +464,10 @@ export default function MaturityMatrixToolsAdmin() {
<div>
<h3 className="admin-matrix-tools__h3">Import</h3>
<p className="muted admin-matrix-tools__hint">
<code className="admin-bindings__code">shinkan.matrix_stack.v1</code> (Komplett-Stack),{' '}
<code className="admin-bindings__code">shinkan.maturity_model.v1</code> oder{' '}
<code className="admin-bindings__code">shinkan.maturity_matrix_resolved.v1</code>. Aufgelöste Matrizen
legen ein neues Modell an bzw. ersetzen den Inhalt des Zielmodells (ohne Bindings).
<code className="admin-bindings__code">shinkan.maturity_matrix_resolved.v1</code>. Komplett-Stack bitte im
Abschnitt <strong>Komplett-Stack</strong> oben importieren. Aufgelöste Matrizen legen ein neues Modell an bzw.
ersetzen den Inhalt des Zielmodells (ohne Bindings).
</p>
<label className="form-label">Modus</label>
<select

View File

@ -0,0 +1,248 @@
import React, { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../utils/api'
const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
function resolveMediaUrl(filePath) {
if (!filePath) return null
if (filePath.startsWith('http://') || filePath.startsWith('https://')) return filePath
const p = filePath.startsWith('/') ? filePath : `/${filePath}`
return `${API_BASE}${p}`
}
function MediaBlock({ media }) {
if (media.embed_url) {
return (
<div style={{ marginTop: '0.5rem' }}>
<a href={media.embed_url} target="_blank" rel="noreferrer">
{media.embed_url}
</a>
{media.embed_platform && (
<span style={{ color: 'var(--text2)', marginLeft: '0.5rem', fontSize: '0.8rem' }}>
({media.embed_platform})
</span>
)}
</div>
)
}
const src = resolveMediaUrl(media.file_path)
if (!src) return null
if (media.media_type === 'image' || (media.mime_type && media.mime_type.startsWith('image/'))) {
return (
<img
src={src}
alt={media.title || media.original_filename || ''}
style={{ maxWidth: '100%', borderRadius: '8px', marginTop: '0.5rem' }}
/>
)
}
if (media.media_type === 'video' || (media.mime_type && media.mime_type.startsWith('video/'))) {
return <video src={src} controls style={{ width: '100%', marginTop: '0.5rem', borderRadius: '8px' }} />
}
return (
<a href={src} target="_blank" rel="noreferrer" style={{ display: 'inline-block', marginTop: '0.5rem' }}>
{media.title || media.original_filename || 'Datei öffnen'}
</a>
)
}
function ExerciseDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
const [exercise, setExercise] = useState(null)
const [error, setError] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
const load = async () => {
setLoading(true)
setError(null)
try {
const data = await api.getExercise(id)
if (!cancelled) setExercise(data)
} catch (err) {
if (!cancelled) setError(err)
} finally {
if (!cancelled) setLoading(false)
}
}
if (id) load()
return () => {
cancelled = true
}
}, [id])
if (loading) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="spinner"></div>
<p>Laden...</p>
</div>
)
}
if (error) {
const msg = error.message || String(error)
return (
<div style={{ padding: '2rem', maxWidth: '720px', margin: '0 auto' }}>
<div className="card">
<h2>Übung</h2>
<p style={{ color: 'var(--danger)' }}>{msg}</p>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}>
Zur Übersicht
</button>
</div>
</div>
)
}
if (!exercise) return null
const chips = (items, labelKey = 'name') =>
(items || []).length ? (items || []).map((x) => x[labelKey]).join(', ') : '—'
return (
<div style={{ padding: '2rem' }}>
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
<div style={{ marginBottom: '1rem' }}>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}>
Übersicht
</button>
</div>
<div className="card" style={{ marginBottom: '1rem' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '1rem',
flexWrap: 'wrap',
}}
>
<h1 style={{ margin: 0 }}>{exercise.title}</h1>
<Link to={`/exercises/${exercise.id}/edit`} className="btn btn-primary">
Bearbeiten
</Link>
</div>
{exercise.summary && (
<p style={{ color: 'var(--text2)', marginTop: '1rem' }}>{exercise.summary}</p>
)}
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '1rem' }}>
<span className="badge">{exercise.visibility}</span>
<span className="badge">{exercise.status}</span>
{exercise.club_name && <span className="badge">{exercise.club_name}</span>}
</div>
</div>
<section className="card" style={{ marginBottom: '1rem' }}>
<h2 style={{ marginTop: 0 }}>Zuordnung</h2>
<p>
<strong>Fokusbereiche:</strong> {chips(exercise.focus_areas)}
</p>
<p>
<strong>Stilrichtungen:</strong> {chips(exercise.training_styles)}
</p>
<p>
<strong>Zielgruppen:</strong> {chips(exercise.target_groups)}
</p>
<p>
<strong>Altersgruppen:</strong>{' '}
{(exercise.age_groups || []).length ? exercise.age_groups.join(', ') : '—'}
</p>
</section>
{exercise.goal && (
<section className="card" style={{ marginBottom: '1rem' }}>
<h2 style={{ marginTop: 0 }}>Ziel</h2>
<p style={{ whiteSpace: 'pre-wrap' }}>{exercise.goal}</p>
</section>
)}
{exercise.execution && (
<section className="card" style={{ marginBottom: '1rem' }}>
<h2 style={{ marginTop: 0 }}>Durchführung</h2>
<p style={{ whiteSpace: 'pre-wrap' }}>{exercise.execution}</p>
</section>
)}
{(exercise.preparation || exercise.trainer_notes) && (
<section className="card" style={{ marginBottom: '1rem' }}>
<h2 style={{ marginTop: 0 }}>Trainer</h2>
{exercise.preparation && (
<>
<h3 style={{ fontSize: '1rem' }}>Vorbereitung</h3>
<p style={{ whiteSpace: 'pre-wrap' }}>{exercise.preparation}</p>
</>
)}
{exercise.trainer_notes && (
<>
<h3 style={{ fontSize: '1rem' }}>Hinweise</h3>
<p style={{ whiteSpace: 'pre-wrap' }}>{exercise.trainer_notes}</p>
</>
)}
</section>
)}
{exercise.equipment && exercise.equipment.length > 0 && (
<section className="card" style={{ marginBottom: '1rem' }}>
<h2 style={{ marginTop: 0 }}>Material</h2>
<ul>
{exercise.equipment.map((x, i) => (
<li key={i}>{x}</li>
))}
</ul>
</section>
)}
{(exercise.skills || []).length > 0 && (
<section className="card" style={{ marginBottom: '1rem' }}>
<h2 style={{ marginTop: 0 }}>Fähigkeiten</h2>
<ul style={{ paddingLeft: '1.25rem' }}>
{exercise.skills.map((s) => (
<li key={s.id}>
{s.skill_name}
{s.skill_category ? ` (${s.skill_category})` : ''}
{s.is_primary ? ' · primär' : ''}
{s.intensity ? ` · ${s.intensity}` : ''}
</li>
))}
</ul>
</section>
)}
{(exercise.variants || []).length > 0 && (
<section className="card" style={{ marginBottom: '1rem' }}>
<h2 style={{ marginTop: 0 }}>Varianten</h2>
{exercise.variants.map((v) => (
<div key={v.id} style={{ marginBottom: '1rem', paddingBottom: '1rem', borderBottom: '1px solid var(--border)' }}>
<strong>{v.variant_name}</strong>
{v.description && <p style={{ color: 'var(--text2)' }}>{v.description}</p>}
{v.execution_changes && (
<p style={{ whiteSpace: 'pre-wrap' }}>{v.execution_changes}</p>
)}
</div>
))}
</section>
)}
{(exercise.media || []).length > 0 && (
<section className="card">
<h2 style={{ marginTop: 0 }}>Medien</h2>
{exercise.media.map((m) => (
<div key={m.id} style={{ marginBottom: '1.5rem' }}>
<strong>{m.title || m.original_filename || m.media_type}</strong>
{m.description && <p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{m.description}</p>}
<MediaBlock media={m} />
</div>
))}
</section>
)}
</div>
</div>
)
}
export default ExerciseDetailPage

View File

@ -0,0 +1,696 @@
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

View File

@ -0,0 +1,231 @@
import React, { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
function ExercisesListPage() {
const [exercises, setExercises] = useState([])
const [focusAreas, setFocusAreas] = useState([])
const [loading, setLoading] = useState(true)
const [filters, setFilters] = useState({
focus_area: '',
visibility: '',
status: '',
})
useEffect(() => {
loadData()
}, [filters])
const loadData = async () => {
try {
const [exercisesData, focusAreasData] = await Promise.all([
api.listExercises(filters),
api.listFocusAreas(),
])
setExercises(exercisesData)
setFocusAreas(focusAreasData)
} catch (err) {
console.error('Failed to load data:', err)
alert('Fehler beim Laden: ' + err.message)
} finally {
setLoading(false)
}
}
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)
}
}
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' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '1.5rem',
flexWrap: 'wrap',
gap: '0.75rem',
}}
>
<h1>Übungen</h1>
<Link to="/exercises/new" className="btn btn-primary">
+ Neue Übung
</Link>
</div>
<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>
{focusAreas.map((fa) => (
<option key={fa.id} value={fa.id}>
{fa.icon} {fa.name}
</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.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' }}>
<Link
to={`/exercises/${exercise.id}`}
style={{ color: 'inherit', textDecoration: 'none' }}
>
{exercise.title}
</Link>
</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', flexWrap: 'wrap' }}>
<Link
to={`/exercises/${exercise.id}`}
className="btn btn-secondary"
style={{ flex: '1 1 100px', textAlign: 'center' }}
>
Ansehen
</Link>
<Link
to={`/exercises/${exercise.id}/edit`}
className="btn btn-secondary"
style={{ flex: '1 1 100px', textAlign: 'center' }}
>
Bearbeiten
</Link>
<button
type="button"
className="btn"
style={{
flex: '1 1 100px',
background: 'var(--danger)',
color: 'white',
border: 'none',
}}
onClick={() => handleDelete(exercise)}
>
Löschen
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
export default ExercisesListPage

View File

@ -1,608 +0,0 @@
import React, { useState, useEffect } from 'react'
import api from '../utils/api'
function ExercisesPage() {
const [exercises, setExercises] = useState([])
const [skills, setSkills] = useState([])
const [focusAreas, setFocusAreas] = useState([])
const [trainingStyles, setTrainingStyles] = useState([])
const [trainingCharacters, setTrainingCharacters] = 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: '',
focus_area_id: null,
secondary_areas: [],
training_style_id: null,
training_character: '',
training_character_id: null,
visibility: 'private',
status: 'draft',
skills: []
})
useEffect(() => {
loadData()
}, [filters])
const loadData = async () => {
try {
const [exercisesData, skillsData, focusAreasData, stylesData, charactersData] = await Promise.all([
api.listExercises(filters),
api.listSkills(),
api.listFocusAreas(),
api.listTrainingStyles(),
api.listTrainingCharacters()
])
setExercises(exercisesData)
setSkills(skillsData)
setFocusAreas(focusAreasData)
setTrainingStyles(stylesData)
setTrainingCharacters(charactersData)
} 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: '',
focus_area_id: null,
secondary_areas: [],
training_style_id: null,
training_character: '',
training_character_id: null,
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 || '',
focus_area_id: exercise.focus_area_id || null,
secondary_areas: exercise.secondary_areas || [],
training_style_id: exercise.training_style_id || null,
training_character: exercise.training_character || '',
training_character_id: exercise.training_character_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 || 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' }}>
{/* 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>
{focusAreas.map(fa => (
<option key={fa.id} value={fa.id}>
{fa.icon} {fa.name}
</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_id || ''}
onChange={(e) => updateFormField('focus_area_id', e.target.value ? parseInt(e.target.value) : null)}
>
<option value="">Bitte wählen</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">Trainingsstil</label>
<select
className="form-input"
value={formData.training_style_id || ''}
onChange={(e) => updateFormField('training_style_id', e.target.value ? parseInt(e.target.value) : null)}
>
<option value="">Bitte wählen</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 className="form-row">
<label className="form-label">Trainingscharakter</label>
<select
className="form-input"
value={formData.training_character_id || ''}
onChange={(e) => updateFormField('training_character_id', e.target.value ? parseInt(e.target.value) : null)}
>
<option value="">Bitte wählen</option>
{trainingCharacters.map(tc => (
<option key={tc.id} value={tc.id}>
{tc.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>
{/* 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>
)
}
export default ExercisesPage

View File

@ -209,10 +209,98 @@ export async function deleteMethod(id) {
// ============================================================================
export async function listExercises(filters = {}) {
const query = new URLSearchParams(filters).toString()
const q = new URLSearchParams()
Object.entries(filters).forEach(([k, v]) => {
if (v !== undefined && v !== null && String(v).trim() !== '') {
q.set(k, String(v))
}
})
const query = q.toString()
return request(`/api/exercises${query ? '?' + query : ''}`)
}
/** Formular → API-Body (M:N gemäß EXERCISES_API_SPEC) */
export function buildExerciseApiPayload(formData) {
const num = (v) => (v === '' || v == null ? null : Number(v))
const goal = (formData.goal || '').trim()
const execution = (formData.execution || '').trim()
if (!goal && !execution) {
throw new Error('Ziel oder Durchführung ausfüllen (mindestens eines).')
}
return {
title: (formData.title || '').trim(),
summary: formData.summary || null,
goal: goal || null,
execution: execution || null,
preparation: formData.preparation || null,
trainer_notes: formData.trainer_notes || null,
duration_min: num(formData.duration_min),
duration_max: num(formData.duration_max),
group_size_min: num(formData.group_size_min),
group_size_max: num(formData.group_size_max),
equipment: Array.isArray(formData.equipment) ? formData.equipment : [],
focus_areas_multi: formData.focus_area_id
? [{ focus_area_id: formData.focus_area_id, is_primary: true }]
: [],
training_styles_multi: formData.training_style_id
? [{ training_style_id: formData.training_style_id, is_primary: true }]
: [],
target_groups_multi: [],
age_groups: formData.age_groups || [],
skills: (formData.skills || []).map((s) => ({
skill_id: s.skill_id,
is_primary: !!s.is_primary,
intensity: s.intensity || null,
required_level: s.required_level || null,
target_level: s.target_level || null,
})),
visibility: formData.visibility || 'private',
status: formData.status || 'draft',
club_id: formData.club_id ?? null,
}
}
export async function uploadExerciseMedia(exerciseId, formData) {
const token = localStorage.getItem('authToken')
const headers = {}
if (token) headers['X-Auth-Token'] = token
const response = await fetch(`${API_URL}/api/exercises/${exerciseId}/media`, {
method: 'POST',
headers,
body: formData,
})
if (!response.ok) {
const err = await response.json().catch(() => ({ detail: 'Unknown error' }))
const d = err.detail
const msg =
typeof d === 'string'
? d
: d != null
? JSON.stringify(d)
: `HTTP ${response.status}`
throw new Error(msg)
}
return response.json()
}
export async function updateExerciseMedia(exerciseId, mediaId, data) {
return request(`/api/exercises/${exerciseId}/media/${mediaId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteExerciseMedia(exerciseId, mediaId) {
return request(`/api/exercises/${exerciseId}/media/${mediaId}`, { method: 'DELETE' })
}
export async function reorderExerciseMedia(exerciseId, mediaIds) {
return request(`/api/exercises/${exerciseId}/media/reorder`, {
method: 'PUT',
body: JSON.stringify({ media_ids: mediaIds }),
})
}
export async function getExercise(id) {
return request(`/api/exercises/${id}`)
}
@ -679,6 +767,11 @@ export const api = {
createExercise,
updateExercise,
deleteExercise,
buildExerciseApiPayload,
uploadExerciseMedia,
updateExerciseMedia,
deleteExerciseMedia,
reorderExerciseMedia,
// Training Planning
listTrainingUnits,