diff --git a/backend/main.py b/backend/main.py
index 9357374..0949bab 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -3,9 +3,12 @@ Shinkan Jinkendo - Main Application Entry Point
Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung
"""
+from pathlib import Path
+
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
+from fastapi.staticfiles import StaticFiles
import os
from version import APP_VERSION, BUILD_DATE, DB_SCHEMA_VERSION, MODULE_VERSIONS
@@ -84,6 +87,11 @@ app.include_router(matrix_stack_bundle.router)
app.include_router(import_wiki.router)
app.include_router(import_wiki_admin.router)
+# Lokale Medien (Übungen-Uploads) unter MEDIA_ROOT, ausliefern unter /media/...
+_media_dir = os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent / "media"))
+Path(_media_dir).mkdir(parents=True, exist_ok=True)
+app.mount("/media", StaticFiles(directory=_media_dir), name="media")
+
if __name__ == "__main__":
import uvicorn
uvicorn.run(
diff --git a/backend/migrations/028_exercise_media_and_skills_api.sql b/backend/migrations/028_exercise_media_and_skills_api.sql
new file mode 100644
index 0000000..63769eb
--- /dev/null
+++ b/backend/migrations/028_exercise_media_and_skills_api.sql
@@ -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;
diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py
index 0b3bcc0..7ba23ba 100644
--- a/backend/routers/exercises.py
+++ b/backend/routers/exercises.py
@@ -4,12 +4,15 @@ Exercises Router - v2.0 (Clean-Room Rebuild)
Komplett neu gebaut nach EXERCISES_API_SPEC.md v1.2
KEIN Legacy-Code aus v1 - nur M:N Relations, keine JSONB-Felder für Kataloge
"""
+import hashlib
import json
import logging
+import os
+from pathlib import Path
from typing import Optional
-from fastapi import APIRouter, HTTPException, Depends, Query
-from pydantic import BaseModel, Field
+from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File, Form
+from pydantic import BaseModel, Field, model_validator
from db import get_db, get_cursor, r2d
from auth import require_auth
@@ -18,17 +21,24 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["exercises"])
+MEDIA_ROOT = Path(os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent.parent / "media")))
+MAX_EXERCISE_MEDIA = 10
+MAX_UPLOAD_BYTES = 50 * 1024 * 1024
+ALLOWED_UPLOAD_MIMES = frozenset(
+ {"image/jpeg", "image/png", "image/gif", "video/mp4", "application/pdf"}
+)
+
# ============================================================================
# Pydantic Models
# ============================================================================
class ExerciseCreate(BaseModel):
- # Basis-Felder
+ # Basis-Felder (goal/execution: DB-Constraint mind. eines; Wiki oft nur eines)
title: str = Field(..., min_length=3, max_length=300)
summary: Optional[str] = None
- goal: str = Field(..., min_length=10, max_length=5000)
- execution: str = Field(..., min_length=10, max_length=10000)
+ goal: Optional[str] = Field(None, max_length=5000)
+ execution: Optional[str] = Field(None, max_length=10000)
preparation: Optional[str] = None
trainer_notes: Optional[str] = None
@@ -55,13 +65,23 @@ class ExerciseCreate(BaseModel):
status: str = "draft"
club_id: Optional[int] = None
+ @model_validator(mode="after")
+ def normalize_goal_execution(self):
+ g = (self.goal or "").strip() or None
+ e = (self.execution or "").strip() or None
+ if not g and not e:
+ raise ValueError("Mindestens eines der Felder Ziel oder Durchführung ist erforderlich")
+ self.goal = g
+ self.execution = e
+ return self
+
class ExerciseUpdate(BaseModel):
# Alle Felder optional für Partial Update
title: Optional[str] = Field(None, min_length=3, max_length=300)
summary: Optional[str] = None
- goal: Optional[str] = Field(None, min_length=10, max_length=5000)
- execution: Optional[str] = Field(None, min_length=10, max_length=10000)
+ goal: Optional[str] = Field(None, max_length=5000)
+ execution: Optional[str] = Field(None, max_length=10000)
preparation: Optional[str] = None
trainer_notes: Optional[str] = None
duration_min: Optional[int] = None
@@ -78,11 +98,87 @@ class ExerciseUpdate(BaseModel):
status: Optional[str] = None
club_id: Optional[int] = None
+ @model_validator(mode="after")
+ def normalize_goal_execution(self):
+ if self.goal is not None:
+ self.goal = self.goal.strip() or None
+ if self.execution is not None:
+ self.execution = self.execution.strip() or None
+ return self
+
+
+class ExerciseMediaUpdate(BaseModel):
+ title: Optional[str] = None
+ description: Optional[str] = None
+ is_primary: Optional[bool] = None
+ context: Optional[str] = None
+
+
+class ExerciseMediaReorder(BaseModel):
+ media_ids: list[int]
+
# ============================================================================
# Helper Functions
# ============================================================================
+def _row_created_by(row) -> int:
+ if row is None:
+ return None
+ if isinstance(row, dict):
+ return row.get("created_by")
+ return row[0]
+
+
+def _ensure_media_dirs():
+ sub = MEDIA_ROOT / "exercises"
+ sub.mkdir(parents=True, exist_ok=True)
+ return sub
+
+
+def _detect_embed_platform(url: str) -> Optional[str]:
+ if not url:
+ return None
+ u = url.lower()
+ if "youtube.com" in u or "youtu.be" in u:
+ return "youtube"
+ if "vimeo.com" in u:
+ return "vimeo"
+ if "instagram.com" in u:
+ return "instagram"
+ if "tiktok.com" in u:
+ return "tiktok"
+ return None
+
+
+def _assert_can_edit_exercise(cur, exercise_id: int, profile_id: int):
+ cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,))
+ row = cur.fetchone()
+ if not row:
+ raise HTTPException(status_code=404, detail="Übung nicht gefunden")
+ if _row_created_by(row) != profile_id:
+ raise HTTPException(status_code=403, detail="Nur der Ersteller darf diese Übung bearbeiten")
+
+
+def _count_exercise_media(cur, exercise_id: int) -> int:
+ cur.execute("SELECT COUNT(*) AS c FROM exercise_media WHERE exercise_id = %s", (exercise_id,))
+ r = cur.fetchone()
+ return int(r["c"] if isinstance(r, dict) else r[0])
+
+
+def _abs_media_path(file_path_db: str) -> Optional[Path]:
+ if not file_path_db or file_path_db.startswith("http"):
+ return None
+ rel = file_path_db.lstrip("/")
+ if rel.startswith("media/"):
+ rel = rel[len("media/") :]
+ p = MEDIA_ROOT / rel
+ try:
+ p.resolve().relative_to(MEDIA_ROOT.resolve())
+ except ValueError:
+ return None
+ return p
+
def enrich_exercise_detail(exercise_id: int, cur) -> dict:
"""
Lädt alle M:N Relations für eine Übung und gibt ein vollständiges
@@ -314,12 +410,19 @@ def list_exercises(
where.append("e.search_vector @@ plainto_tsquery('german', %s)")
params.append(search)
- # Query
+ # Query (primary_focus_name für Listen-Ansicht gemäß Spec-Beispiel „focus_area“-Label)
query = f"""
SELECT e.id, e.title, e.summary, e.visibility, e.status,
e.created_by, p.name as creator_name,
e.club_id, c.name as club_name,
- e.created_at, e.updated_at
+ e.created_at, e.updated_at,
+ (
+ SELECT fa.name FROM exercise_focus_areas efa
+ JOIN focus_areas fa ON fa.id = efa.focus_area_id
+ WHERE efa.exercise_id = e.id
+ ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
+ LIMIT 1
+ ) AS primary_focus_name
FROM exercises e
LEFT JOIN profiles p ON e.created_by = p.id
LEFT JOIN clubs c ON e.club_id = c.id
@@ -332,7 +435,13 @@ def list_exercises(
cur.execute(query, params)
rows = cur.fetchall()
- return [r2d(r) for r in rows]
+ out = []
+ for r in rows:
+ d = r2d(r)
+ pfn = d.get("primary_focus_name")
+ d["focus_area"] = pfn
+ out.append(d)
+ return out
@router.get("/exercises/{exercise_id}")
@@ -434,7 +543,7 @@ def update_exercise(
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
# Permission Check
- if row[0] != profile_id:
+ if _row_created_by(row) != profile_id:
raise HTTPException(status_code=403, detail="Nur der Ersteller darf editieren")
# UPDATE (nur gesetzte Felder)
@@ -495,15 +604,16 @@ def delete_exercise(
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
# Permission Check
- if row[0] != profile_id and role not in ("admin", "superadmin"):
+ if _row_created_by(row) != profile_id and role not in ("admin", "superadmin"):
raise HTTPException(status_code=403, detail="Nur Ersteller oder Admin darf löschen")
- # Prüfen ob Übung in Trainingseinheiten verwendet wird
+ # Prüfen ob Übung in Block-Items verwendet wird
cur.execute(
- "SELECT COUNT(*) FROM exercise_block_items WHERE exercise_id = %s",
+ "SELECT COUNT(*) AS cnt FROM exercise_block_items WHERE exercise_id = %s",
(exercise_id,)
)
- count = cur.fetchone()[0]
+ crow = cur.fetchone()
+ count = crow["cnt"] if isinstance(crow, dict) else crow[0]
if count > 0:
raise HTTPException(
status_code=409,
@@ -515,3 +625,244 @@ def delete_exercise(
conn.commit()
return {"ok": True}
+
+
+# --- Medien (MEDIA_UPLOAD_SPEC.md / EXERCISES_API_SPEC.md) ---
+
+
+def _fetch_media_row(cur, exercise_id: int, media_id: int) -> Optional[dict]:
+ cur.execute(
+ """SELECT id, exercise_id, media_type, file_path, file_size, mime_type, original_filename,
+ embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at
+ FROM exercise_media WHERE id = %s AND exercise_id = %s""",
+ (media_id, exercise_id),
+ )
+ row = cur.fetchone()
+ return r2d(row) if row else None
+
+
+@router.post("/exercises/{exercise_id}/media", status_code=201)
+async def upload_exercise_media(
+ exercise_id: int,
+ session: dict = Depends(require_auth),
+ file: Optional[UploadFile] = File(None),
+ embed_url: Optional[str] = Form(None),
+ media_type: str = Form(...),
+ title: str = Form(""),
+ description: str = Form(""),
+ context: str = Form("ablauf"),
+ is_primary: bool = Form(False),
+):
+ profile_id = session["profile_id"]
+ if media_type not in ("image", "video", "document", "sketch"):
+ raise HTTPException(status_code=400, detail="Ungültiger media_type")
+ if context not in ("ablauf", "detail", "trainer_hint"):
+ raise HTTPException(status_code=400, detail="Ungültiger context")
+
+ emb = (embed_url or "").strip() or None
+ has_file = file is not None and bool(file.filename)
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ _assert_can_edit_exercise(cur, exercise_id, profile_id)
+
+ if _count_exercise_media(cur, exercise_id) >= MAX_EXERCISE_MEDIA:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Maximal {MAX_EXERCISE_MEDIA} Medien pro Übung",
+ )
+
+ if has_file and emb:
+ raise HTTPException(status_code=400, detail="Entweder Datei oder embed_url, nicht beides")
+ if not has_file and not emb:
+ raise HTTPException(status_code=400, detail="Datei oder embed_url erforderlich")
+
+ sort_sql = (
+ "COALESCE((SELECT MAX(sort_order) + 1 FROM exercise_media WHERE exercise_id = %s), 1)"
+ )
+
+ if emb:
+ platform = _detect_embed_platform(emb)
+ if not platform:
+ raise HTTPException(
+ status_code=400,
+ detail="Ungültige Embed-URL (erlaubt: YouTube, Vimeo, Instagram, TikTok)",
+ )
+ cur.execute(
+ f"""INSERT INTO exercise_media (
+ exercise_id, media_type, file_path, file_size, mime_type, original_filename,
+ embed_url, embed_platform, title, description, context, is_primary, sort_order
+ ) VALUES (
+ %s, %s, NULL, NULL, NULL, NULL, %s, %s, %s, %s, %s, %s, {sort_sql}
+ )
+ RETURNING id, exercise_id, media_type, file_path, file_size, mime_type, original_filename,
+ embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at""",
+ (
+ exercise_id,
+ media_type,
+ emb,
+ platform,
+ title or None,
+ description or None,
+ context,
+ is_primary,
+ exercise_id,
+ ),
+ )
+ else:
+ raw = await file.read()
+ if len(raw) > MAX_UPLOAD_BYTES:
+ raise HTTPException(
+ status_code=413,
+ detail=f"Datei zu groß (max. {MAX_UPLOAD_BYTES // (1024 * 1024)} MB)",
+ )
+ mime = file.content_type or ""
+ if mime not in ALLOWED_UPLOAD_MIMES:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Dateityp nicht erlaubt: {mime or 'unbekannt'}",
+ )
+ ext = Path(file.filename or "").suffix[:12] if file.filename else ""
+ if not ext and mime == "image/jpeg":
+ ext = ".jpg"
+ elif not ext and mime == "image/png":
+ ext = ".png"
+ digest = hashlib.sha256(raw).hexdigest()[:12]
+ fname = f"{digest}_{exercise_id}{ext}"
+ dest_dir = _ensure_media_dirs()
+ dest_path = dest_dir / fname
+ dest_path.write_bytes(raw)
+ db_path = f"/media/exercises/{fname}"
+ cur.execute(
+ f"""INSERT INTO exercise_media (
+ exercise_id, media_type, file_path, file_size, mime_type, original_filename,
+ embed_url, embed_platform, title, description, context, is_primary, sort_order
+ ) VALUES (
+ %s, %s, %s, %s, %s, %s, NULL, NULL, %s, %s, %s, %s, {sort_sql}
+ )
+ RETURNING id, exercise_id, media_type, file_path, file_size, mime_type, original_filename,
+ embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at""",
+ (
+ exercise_id,
+ media_type,
+ db_path,
+ len(raw),
+ mime,
+ file.filename,
+ title or None,
+ description or None,
+ context,
+ is_primary,
+ exercise_id,
+ ),
+ )
+ row = cur.fetchone()
+ conn.commit()
+ return r2d(row)
+
+
+@router.put("/exercises/{exercise_id}/media/reorder")
+def reorder_exercise_media(
+ exercise_id: int,
+ body: ExerciseMediaReorder,
+ session: dict = Depends(require_auth),
+):
+ profile_id = session["profile_id"]
+ ids = body.media_ids
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ _assert_can_edit_exercise(cur, exercise_id, profile_id)
+ cur.execute(
+ "SELECT id FROM exercise_media WHERE exercise_id = %s ORDER BY sort_order, id",
+ (exercise_id,),
+ )
+ existing = [r["id"] if isinstance(r, dict) else r[0] for r in cur.fetchall()]
+ if set(ids) != set(existing) or len(ids) != len(existing):
+ raise HTTPException(
+ status_code=400,
+ detail="media_ids unvollständig oder gehören nicht zu dieser Übung",
+ )
+ for i, mid in enumerate(ids, start=1):
+ cur.execute(
+ "UPDATE exercise_media SET sort_order = %s, updated_at = NOW() WHERE id = %s AND exercise_id = %s",
+ (i, mid, exercise_id),
+ )
+ conn.commit()
+ return {"ok": True, "reordered": len(ids)}
+
+
+@router.put("/exercises/{exercise_id}/media/{media_id}")
+def update_exercise_media(
+ exercise_id: int,
+ media_id: int,
+ body: ExerciseMediaUpdate,
+ session: dict = Depends(require_auth),
+):
+ profile_id = session["profile_id"]
+ data = body.dict(exclude_unset=True)
+ if not data:
+ raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ _assert_can_edit_exercise(cur, exercise_id, profile_id)
+ if not _fetch_media_row(cur, exercise_id, media_id):
+ raise HTTPException(status_code=404, detail="Medium nicht gefunden")
+ if "context" in data and data["context"] not in ("ablauf", "detail", "trainer_hint", None):
+ raise HTTPException(status_code=400, detail="Ungültiger context")
+
+ fields = []
+ params = []
+ for k in ("title", "description", "is_primary", "context"):
+ if k in data:
+ fields.append(f"{k} = %s")
+ params.append(data[k])
+ if fields:
+ fields.append("updated_at = NOW()")
+ params.extend([media_id, exercise_id])
+ cur.execute(
+ f"UPDATE exercise_media SET {', '.join(fields)} WHERE id = %s AND exercise_id = %s",
+ params,
+ )
+ conn.commit()
+ cur.execute(
+ """SELECT id, media_type, file_path, file_size, mime_type, original_filename,
+ embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at
+ FROM exercise_media WHERE id = %s""",
+ (media_id,),
+ )
+ return r2d(cur.fetchone())
+
+
+@router.delete("/exercises/{exercise_id}/media/{media_id}")
+def delete_exercise_media(
+ exercise_id: int,
+ media_id: int,
+ session: dict = Depends(require_auth),
+):
+ profile_id = session["profile_id"]
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ _assert_can_edit_exercise(cur, exercise_id, profile_id)
+ cur.execute(
+ """SELECT file_path FROM exercise_media WHERE id = %s AND exercise_id = %s""",
+ (media_id, exercise_id),
+ )
+ row = cur.fetchone()
+ if not row:
+ raise HTTPException(status_code=404, detail="Medium nicht gefunden")
+ fp = row["file_path"] if isinstance(row, dict) else row[0]
+ cur.execute(
+ "DELETE FROM exercise_media WHERE id = %s AND exercise_id = %s",
+ (media_id, exercise_id),
+ )
+ conn.commit()
+
+ abs_p = _abs_media_path(fp) if fp else None
+ if abs_p and abs_p.is_file():
+ try:
+ abs_p.unlink()
+ except OSError as e:
+ logger.warning("Medien-Datei konnte nicht gelöscht werden: %s", e)
+
+ return {"ok": True}
diff --git a/backend/routers/matrix_stack_bundle.py b/backend/routers/matrix_stack_bundle.py
index 967cdea..8ac812c 100644
--- a/backend/routers/matrix_stack_bundle.py
+++ b/backend/routers/matrix_stack_bundle.py
@@ -12,7 +12,7 @@ import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
@@ -336,7 +336,10 @@ def _upsert_skill_category(
return old_id, new_id
-def import_matrix_stack_v1(data: Dict[str, Any], session: dict = Depends(require_auth)) -> Dict[str, Any]:
+def import_matrix_stack_v1(
+ data: Dict[str, Any] = Body(...),
+ session: dict = Depends(require_auth),
+) -> Dict[str, Any]:
_require_admin(session)
if data.get("kind") != KIND_V1:
raise HTTPException(400, f"kind muss {KIND_V1} sein")
diff --git a/backend/version.py b/backend/version.py
index 51a54ad..a3d5c35 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -2,7 +2,7 @@
APP_VERSION = "0.7.6"
BUILD_DATE = "2026-04-27"
-DB_SCHEMA_VERSION = "20260427027"
+DB_SCHEMA_VERSION = "20260427028"
MODULE_VERSIONS = {
"auth": "1.0.0",
@@ -28,6 +28,7 @@ CHANGELOG = [
"date": "2026-04-27",
"changes": [
"API: GET/POST /api/admin/matrix-stack (shinkan.matrix_stack.v1) – Fähigkeitskatalog, Reifegradmodelle, Kontext-Bindings für Test→Prod",
+ "DB 028: exercise_media (Embed/Metadaten), exercise_skills VARCHAR-Level/Intensität; API: POST/PUT/DELETE Medien, /media Static; Übungen-Listen-Fokus",
],
},
{
diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md
index 9d91831..a6f9fdf 100644
--- a/docs/HANDOVER.md
+++ b/docs/HANDOVER.md
@@ -51,7 +51,7 @@ Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im
- **`frontend/src/pages/AdminMaturityModelsPage.jsx`**: Tabs u. a. Katalog, Modelle, Kontext-Zuordnung, **Matrix-Ansicht und Export**.
- **`MaturityModelBindingsAdmin.jsx`**: Bindings CRUD, Erklärung Merge/Legacy.
-- **`MaturityMatrixToolsAdmin.jsx`**: Kontext auflösen, hierarchische Matrix-Ansicht, Export einzelnes Modell / aufgelöst, Import, **Komplett-Stack** Export/Import.
+- **`MaturityMatrixToolsAdmin.jsx`**: Kontext auflösen, hierarchische Matrix-Ansicht, Export einzelnes Modell / aufgelöst, Einzelmodell-Import; **Komplett-Stack** mit eigenem Export-Button und **eigenem Dateifeld für Stack-Import** (`POST /api/admin/matrix-stack/import`).
- **`frontend/src/utils/api.js`**: u. a. `exportMatrixStackBundle`, `importMatrixStackBundle`, Reifegrad-APIs.
---
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 11eb6a8..0c065f4 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,12 +1,22 @@
import React from 'react'
-import { BrowserRouter as Router, Routes, Route, Navigate, NavLink, useLocation } from 'react-router-dom'
+import {
+ BrowserRouter as Router,
+ Routes,
+ Route,
+ Navigate,
+ NavLink,
+ useLocation,
+ Outlet,
+} from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import DesktopSidebar from './components/DesktopSidebar'
import { getMainNavItems } from './config/appNav'
import LoginPage from './pages/LoginPage'
import Dashboard from './pages/Dashboard'
import ProfilePage from './pages/ProfilePage'
-import ExercisesPage from './pages/ExercisesPage'
+import ExercisesListPage from './pages/ExercisesListPage'
+import ExerciseDetailPage from './pages/ExerciseDetailPage'
+import ExerciseFormPage from './pages/ExerciseFormPage'
import ClubsPage from './pages/ClubsPage'
import SkillsPage from './pages/SkillsPage'
import TrainingPlanningPage from './pages/TrainingPlanningPage'
@@ -35,8 +45,7 @@ function Nav({ isAdmin }) {
to={item.to}
end={!!item.end}
className={({ isActive }) =>
- 'nav-item' +
- (navItemActive(loc.pathname, item, isActive) ? ' active' : '')
+ 'nav-item' + (navItemActive(loc.pathname, item, isActive) ? ' active' : '')
}
>
- Datei mit kind: shinkan.matrix_stack.v1. Katalog wird per Slug
- zusammengeführt; Skills per Kategorie + Name. Optional alle Reifegradmodelle auf der Ziel-DB vorher löschen
- (nur Superadmin, Vorsicht).
+ JSON vom Export dieses Dialogs (shinkan.matrix_stack.v1). Katalog
+ wird per Slug zusammengeführt; Skills per Kategorie + Name. Reifegradmodelle werden neu angelegt; Kontext-Bindings
+ über Namen der Fokus-/Stil-/Trainingsstil-Kataloge auf der Ziel-DB.