feat: enhance exercise management and media handling
- 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:
parent
2452b5e2e8
commit
cb11e39201
|
|
@ -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(
|
||||
|
|
|
|||
72
backend/migrations/028_exercise_media_and_skills_api.sql
Normal file
72
backend/migrations/028_exercise_media_and_skills_api.sql
Normal 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;
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
248
frontend/src/pages/ExerciseDetailPage.jsx
Normal file
248
frontend/src/pages/ExerciseDetailPage.jsx
Normal 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
|
||||
696
frontend/src/pages/ExerciseFormPage.jsx
Normal file
696
frontend/src/pages/ExerciseFormPage.jsx
Normal 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
|
||||
231
frontend/src/pages/ExercisesListPage.jsx
Normal file
231
frontend/src/pages/ExercisesListPage.jsx
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user