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
|
Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung
|
||||||
"""
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from version import APP_VERSION, BUILD_DATE, DB_SCHEMA_VERSION, MODULE_VERSIONS
|
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.router)
|
||||||
app.include_router(import_wiki_admin.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__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(
|
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
|
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
|
KEIN Legacy-Code aus v1 - nur M:N Relations, keine JSONB-Felder für Kataloge
|
||||||
"""
|
"""
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File, Form
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
from auth import require_auth
|
from auth import require_auth
|
||||||
|
|
@ -18,17 +21,24 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["exercises"])
|
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
|
# Pydantic Models
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class ExerciseCreate(BaseModel):
|
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)
|
title: str = Field(..., min_length=3, max_length=300)
|
||||||
summary: Optional[str] = None
|
summary: Optional[str] = None
|
||||||
goal: str = Field(..., min_length=10, max_length=5000)
|
goal: Optional[str] = Field(None, max_length=5000)
|
||||||
execution: str = Field(..., min_length=10, max_length=10000)
|
execution: Optional[str] = Field(None, max_length=10000)
|
||||||
preparation: Optional[str] = None
|
preparation: Optional[str] = None
|
||||||
trainer_notes: Optional[str] = None
|
trainer_notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
@ -55,13 +65,23 @@ class ExerciseCreate(BaseModel):
|
||||||
status: str = "draft"
|
status: str = "draft"
|
||||||
club_id: Optional[int] = None
|
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):
|
class ExerciseUpdate(BaseModel):
|
||||||
# Alle Felder optional für Partial Update
|
# Alle Felder optional für Partial Update
|
||||||
title: Optional[str] = Field(None, min_length=3, max_length=300)
|
title: Optional[str] = Field(None, min_length=3, max_length=300)
|
||||||
summary: Optional[str] = None
|
summary: Optional[str] = None
|
||||||
goal: Optional[str] = Field(None, min_length=10, max_length=5000)
|
goal: Optional[str] = Field(None, max_length=5000)
|
||||||
execution: Optional[str] = Field(None, min_length=10, max_length=10000)
|
execution: Optional[str] = Field(None, max_length=10000)
|
||||||
preparation: Optional[str] = None
|
preparation: Optional[str] = None
|
||||||
trainer_notes: Optional[str] = None
|
trainer_notes: Optional[str] = None
|
||||||
duration_min: Optional[int] = None
|
duration_min: Optional[int] = None
|
||||||
|
|
@ -78,11 +98,87 @@ class ExerciseUpdate(BaseModel):
|
||||||
status: Optional[str] = None
|
status: Optional[str] = None
|
||||||
club_id: Optional[int] = 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
|
# 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:
|
def enrich_exercise_detail(exercise_id: int, cur) -> dict:
|
||||||
"""
|
"""
|
||||||
Lädt alle M:N Relations für eine Übung und gibt ein vollständiges
|
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)")
|
where.append("e.search_vector @@ plainto_tsquery('german', %s)")
|
||||||
params.append(search)
|
params.append(search)
|
||||||
|
|
||||||
# Query
|
# Query (primary_focus_name für Listen-Ansicht gemäß Spec-Beispiel „focus_area“-Label)
|
||||||
query = f"""
|
query = f"""
|
||||||
SELECT e.id, e.title, e.summary, e.visibility, e.status,
|
SELECT e.id, e.title, e.summary, e.visibility, e.status,
|
||||||
e.created_by, p.name as creator_name,
|
e.created_by, p.name as creator_name,
|
||||||
e.club_id, c.name as club_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
|
FROM exercises e
|
||||||
LEFT JOIN profiles p ON e.created_by = p.id
|
LEFT JOIN profiles p ON e.created_by = p.id
|
||||||
LEFT JOIN clubs c ON e.club_id = c.id
|
LEFT JOIN clubs c ON e.club_id = c.id
|
||||||
|
|
@ -332,7 +435,13 @@ def list_exercises(
|
||||||
cur.execute(query, params)
|
cur.execute(query, params)
|
||||||
rows = cur.fetchall()
|
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}")
|
@router.get("/exercises/{exercise_id}")
|
||||||
|
|
@ -434,7 +543,7 @@ def update_exercise(
|
||||||
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
|
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
|
||||||
|
|
||||||
# Permission Check
|
# 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")
|
raise HTTPException(status_code=403, detail="Nur der Ersteller darf editieren")
|
||||||
|
|
||||||
# UPDATE (nur gesetzte Felder)
|
# UPDATE (nur gesetzte Felder)
|
||||||
|
|
@ -495,15 +604,16 @@ def delete_exercise(
|
||||||
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
|
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
|
||||||
|
|
||||||
# Permission Check
|
# 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")
|
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(
|
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,)
|
(exercise_id,)
|
||||||
)
|
)
|
||||||
count = cur.fetchone()[0]
|
crow = cur.fetchone()
|
||||||
|
count = crow["cnt"] if isinstance(crow, dict) else crow[0]
|
||||||
if count > 0:
|
if count > 0:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=409,
|
status_code=409,
|
||||||
|
|
@ -515,3 +625,244 @@ def delete_exercise(
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
return {"ok": True}
|
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 datetime import datetime, timezone
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
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.encoders import jsonable_encoder
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
|
@ -336,7 +336,10 @@ def _upsert_skill_category(
|
||||||
return old_id, new_id
|
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)
|
_require_admin(session)
|
||||||
if data.get("kind") != KIND_V1:
|
if data.get("kind") != KIND_V1:
|
||||||
raise HTTPException(400, f"kind muss {KIND_V1} sein")
|
raise HTTPException(400, f"kind muss {KIND_V1} sein")
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
APP_VERSION = "0.7.6"
|
APP_VERSION = "0.7.6"
|
||||||
BUILD_DATE = "2026-04-27"
|
BUILD_DATE = "2026-04-27"
|
||||||
DB_SCHEMA_VERSION = "20260427027"
|
DB_SCHEMA_VERSION = "20260427028"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"auth": "1.0.0",
|
"auth": "1.0.0",
|
||||||
|
|
@ -28,6 +28,7 @@ CHANGELOG = [
|
||||||
"date": "2026-04-27",
|
"date": "2026-04-27",
|
||||||
"changes": [
|
"changes": [
|
||||||
"API: GET/POST /api/admin/matrix-stack (shinkan.matrix_stack.v1) – Fähigkeitskatalog, Reifegradmodelle, Kontext-Bindings für Test→Prod",
|
"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**.
|
- **`frontend/src/pages/AdminMaturityModelsPage.jsx`**: Tabs u. a. Katalog, Modelle, Kontext-Zuordnung, **Matrix-Ansicht und Export**.
|
||||||
- **`MaturityModelBindingsAdmin.jsx`**: Bindings CRUD, Erklärung Merge/Legacy.
|
- **`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.
|
- **`frontend/src/utils/api.js`**: u. a. `exportMatrixStackBundle`, `importMatrixStackBundle`, Reifegrad-APIs.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,22 @@
|
||||||
import React from 'react'
|
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 { AuthProvider, useAuth } from './context/AuthContext'
|
||||||
import DesktopSidebar from './components/DesktopSidebar'
|
import DesktopSidebar from './components/DesktopSidebar'
|
||||||
import { getMainNavItems } from './config/appNav'
|
import { getMainNavItems } from './config/appNav'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import ProfilePage from './pages/ProfilePage'
|
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 ClubsPage from './pages/ClubsPage'
|
||||||
import SkillsPage from './pages/SkillsPage'
|
import SkillsPage from './pages/SkillsPage'
|
||||||
import TrainingPlanningPage from './pages/TrainingPlanningPage'
|
import TrainingPlanningPage from './pages/TrainingPlanningPage'
|
||||||
|
|
@ -35,8 +45,7 @@ function Nav({ isAdmin }) {
|
||||||
to={item.to}
|
to={item.to}
|
||||||
end={!!item.end}
|
end={!!item.end}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
'nav-item' +
|
'nav-item' + (navItemActive(loc.pathname, item, isActive) ? ' active' : '')
|
||||||
(navItemActive(loc.pathname, item, isActive) ? ' active' : '')
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<item.Icon size={20} strokeWidth={2} />
|
<item.Icon size={20} strokeWidth={2} />
|
||||||
|
|
@ -47,8 +56,7 @@ function Nav({ isAdmin }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protected Route Component
|
function ProtectedLayout() {
|
||||||
function ProtectedRoute({ children }) {
|
|
||||||
const { isAuthenticated, loading, user, logout } = useAuth()
|
const { isAuthenticated, loading, user, logout } = useAuth()
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
|
|
@ -60,13 +68,15 @@ function ProtectedRoute({ children }) {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div
|
||||||
|
style={{
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
background: 'var(--bg)'
|
background: 'var(--bg)',
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<div className="spinner"></div>
|
<div className="spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -80,17 +90,15 @@ function ProtectedRoute({ children }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DesktopSidebar
|
<DesktopSidebar isAdmin={isAdmin} user={user} onLogout={handleLogout} />
|
||||||
isAdmin={isAdmin}
|
|
||||||
user={user}
|
|
||||||
onLogout={handleLogout}
|
|
||||||
/>
|
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<div className="app-shell__column">
|
<div className="app-shell__column">
|
||||||
<div className="app-header app-header--mobile">
|
<div className="app-header app-header--mobile">
|
||||||
<div className="app-logo">🥋 Shinkan</div>
|
<div className="app-logo">🥋 Shinkan</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="app-main">{children}</div>
|
<div className="app-main">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
<Nav isAdmin={isAdmin} />
|
<Nav isAdmin={isAdmin} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -98,19 +106,20 @@ function ProtectedRoute({ children }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public Route Component (redirect to dashboard if already logged in)
|
|
||||||
function PublicRoute({ children }) {
|
function PublicRoute({ children }) {
|
||||||
const { isAuthenticated, loading } = useAuth()
|
const { isAuthenticated, loading } = useAuth()
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div
|
||||||
|
style={{
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
background: 'var(--bg)'
|
background: 'var(--bg)',
|
||||||
}}>
|
}}
|
||||||
|
>
|
||||||
<div className="spinner"></div>
|
<div className="spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -122,7 +131,6 @@ function PublicRoute({ children }) {
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Public Routes */}
|
|
||||||
<Route
|
<Route
|
||||||
path="/login"
|
path="/login"
|
||||||
element={
|
element={
|
||||||
|
|
@ -132,101 +140,26 @@ function AppRoutes() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Protected Routes */}
|
<Route element={<ProtectedLayout />}>
|
||||||
<Route
|
<Route index element={<Dashboard />} />
|
||||||
path="/"
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
element={
|
<Route path="exercises">
|
||||||
<ProtectedRoute>
|
<Route index element={<ExercisesListPage />} />
|
||||||
<Dashboard />
|
<Route path="new" element={<ExerciseFormPage />} />
|
||||||
</ProtectedRoute>
|
<Route path=":id/edit" element={<ExerciseFormPage />} />
|
||||||
}
|
<Route path=":id" element={<ExerciseDetailPage />} />
|
||||||
/>
|
</Route>
|
||||||
<Route
|
<Route path="clubs" element={<ClubsPage />} />
|
||||||
path="/profile"
|
<Route path="skills" element={<SkillsPage />} />
|
||||||
element={
|
<Route path="planning" element={<TrainingPlanningPage />} />
|
||||||
<ProtectedRoute>
|
<Route path="admin" element={<Navigate to="/admin/hierarchy" replace />} />
|
||||||
<ProfilePage />
|
<Route path="admin/hierarchy" element={<AdminHierarchyPage />} />
|
||||||
</ProtectedRoute>
|
<Route path="admin/maturity-models" element={<AdminMaturityModelsPage />} />
|
||||||
}
|
<Route path="admin/catalogs" element={<AdminCatalogsPage />} />
|
||||||
/>
|
<Route path="admin/mediawiki-import" element={<MediaWikiImportPage />} />
|
||||||
<Route
|
<Route path="trainer-contexts" element={<TrainerContextsPage />} />
|
||||||
path="/exercises"
|
</Route>
|
||||||
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>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Catch all - redirect to dashboard or login */}
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ export default function MaturityMatrixToolsAdmin() {
|
||||||
const [stackWipe, setStackWipe] = useState(false)
|
const [stackWipe, setStackWipe] = useState(false)
|
||||||
const [stackConfirmText, setStackConfirmText] = useState('')
|
const [stackConfirmText, setStackConfirmText] = useState('')
|
||||||
const [stackLoading, setStackLoading] = useState(false)
|
const [stackLoading, setStackLoading] = useState(false)
|
||||||
|
const [stackImportLoading, setStackImportLoading] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
@ -159,14 +160,19 @@ export default function MaturityMatrixToolsAdmin() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleImportFile(e) {
|
async function handleImportStackFile(e) {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
setError('')
|
setError('')
|
||||||
setMessage('')
|
setMessage('')
|
||||||
|
setStackImportLoading(true)
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(await file.text())
|
const data = JSON.parse(await file.text())
|
||||||
if (data.kind === 'shinkan.matrix_stack.v1') {
|
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') {
|
if (stackWipe && stackConfirmText !== 'DELETE_MATURITY_STACK') {
|
||||||
setError('Vollständiges Ersetzen: Bestätigung exakt „DELETE_MATURITY_STACK“ eintragen (Superadmin).')
|
setError('Vollständiges Ersetzen: Bestätigung exakt „DELETE_MATURITY_STACK“ eintragen (Superadmin).')
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
|
|
@ -180,11 +186,30 @@ export default function MaturityMatrixToolsAdmin() {
|
||||||
const res = await api.importMatrixStackBundle(payload)
|
const res = await api.importMatrixStackBundle(payload)
|
||||||
const w = res.warnings || []
|
const w = res.warnings || []
|
||||||
setMessage(
|
setMessage(
|
||||||
`Stack-Import OK. Modell-Zuordnung: ${Object.keys(res.model_id_map || {}).length} Modell(e).` +
|
`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).` : '')
|
(w.length ? ` ${w.length} Hinweis(e) (Konsole).` : '')
|
||||||
)
|
)
|
||||||
if (w.length) console.warn('matrix_stack import warnings', w)
|
if (w.length) console.warn('matrix_stack import warnings', w)
|
||||||
setModels(await api.listMaturityModels({}))
|
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
|
||||||
|
setError('')
|
||||||
|
setMessage('')
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(await file.text())
|
||||||
|
if (data.kind === 'shinkan.matrix_stack.v1') {
|
||||||
|
setError('Komplett-Stack: bitte die Datei im Abschnitt „Komplett-Stack“ importieren (eigenes Dateifeld).')
|
||||||
|
e.target.value = ''
|
||||||
|
return
|
||||||
} else {
|
} else {
|
||||||
const payload = { ...data, mode: importMode, import_bindings: importBindings }
|
const payload = { ...data, mode: importMode, import_bindings: importBindings }
|
||||||
if (importMode === 'replace') {
|
if (importMode === 'replace') {
|
||||||
|
|
@ -362,17 +387,17 @@ export default function MaturityMatrixToolsAdmin() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
disabled={stackLoading}
|
disabled={stackLoading || stackImportLoading}
|
||||||
onClick={handleExportStack}
|
onClick={handleExportStack}
|
||||||
>
|
>
|
||||||
{stackLoading ? 'Export…' : 'Komplett-Stack exportieren'}
|
{stackLoading ? 'Export…' : 'Komplett-Stack exportieren'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<p className="muted admin-matrix-tools__hint">
|
||||||
Datei mit <code className="admin-bindings__code">kind: shinkan.matrix_stack.v1</code>. Katalog wird per Slug
|
JSON vom Export dieses Dialogs (<code className="admin-bindings__code">shinkan.matrix_stack.v1</code>). Katalog
|
||||||
zusammengeführt; Skills per Kategorie + Name. Optional alle Reifegradmodelle auf der Ziel-DB vorher löschen
|
wird per Slug zusammengeführt; Skills per Kategorie + Name. Reifegradmodelle werden neu angelegt; Kontext-Bindings
|
||||||
(nur Superadmin, Vorsicht).
|
über Namen der Fokus-/Stil-/Trainingsstil-Kataloge auf der Ziel-DB.
|
||||||
</p>
|
</p>
|
||||||
<label className="form-label admin-matrix-tools__check">
|
<label className="form-label admin-matrix-tools__check">
|
||||||
<input
|
<input
|
||||||
|
|
@ -397,6 +422,14 @@ export default function MaturityMatrixToolsAdmin() {
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : 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>
|
||||||
|
|
||||||
<section className="card admin-matrix-tools__section">
|
<section className="card admin-matrix-tools__section">
|
||||||
|
|
@ -431,10 +464,10 @@ export default function MaturityMatrixToolsAdmin() {
|
||||||
<div>
|
<div>
|
||||||
<h3 className="admin-matrix-tools__h3">Import</h3>
|
<h3 className="admin-matrix-tools__h3">Import</h3>
|
||||||
<p className="muted admin-matrix-tools__hint">
|
<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_model.v1</code> oder{' '}
|
||||||
<code className="admin-bindings__code">shinkan.maturity_matrix_resolved.v1</code>. Aufgelöste Matrizen
|
<code className="admin-bindings__code">shinkan.maturity_matrix_resolved.v1</code>. Komplett-Stack bitte im
|
||||||
legen ein neues Modell an bzw. ersetzen den Inhalt des Zielmodells (ohne Bindings).
|
Abschnitt <strong>Komplett-Stack</strong> oben importieren. Aufgelöste Matrizen legen ein neues Modell an bzw.
|
||||||
|
ersetzen den Inhalt des Zielmodells (ohne Bindings).
|
||||||
</p>
|
</p>
|
||||||
<label className="form-label">Modus</label>
|
<label className="form-label">Modus</label>
|
||||||
<select
|
<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 = {}) {
|
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 : ''}`)
|
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) {
|
export async function getExercise(id) {
|
||||||
return request(`/api/exercises/${id}`)
|
return request(`/api/exercises/${id}`)
|
||||||
}
|
}
|
||||||
|
|
@ -679,6 +767,11 @@ export const api = {
|
||||||
createExercise,
|
createExercise,
|
||||||
updateExercise,
|
updateExercise,
|
||||||
deleteExercise,
|
deleteExercise,
|
||||||
|
buildExerciseApiPayload,
|
||||||
|
uploadExerciseMedia,
|
||||||
|
updateExerciseMedia,
|
||||||
|
deleteExerciseMedia,
|
||||||
|
reorderExerciseMedia,
|
||||||
|
|
||||||
// Training Planning
|
// Training Planning
|
||||||
listTrainingUnits,
|
listTrainingUnits,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user