From cb11e39201f517586b94b8adc1db92f57a6e2cac Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 27 Apr 2026 14:27:25 +0200 Subject: [PATCH] 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. --- backend/main.py | 8 + .../028_exercise_media_and_skills_api.sql | 72 ++ backend/routers/exercises.py | 381 +++++++++- backend/routers/matrix_stack_bundle.py | 7 +- backend/version.py | 3 +- docs/HANDOVER.md | 2 +- frontend/src/App.jsx | 177 ++--- .../admin/MaturityMatrixToolsAdmin.jsx | 85 ++- frontend/src/pages/ExerciseDetailPage.jsx | 248 +++++++ frontend/src/pages/ExerciseFormPage.jsx | 696 ++++++++++++++++++ frontend/src/pages/ExercisesListPage.jsx | 231 ++++++ frontend/src/pages/ExercisesPage.jsx | 608 --------------- frontend/src/utils/api.js | 95 ++- 13 files changed, 1837 insertions(+), 776 deletions(-) create mode 100644 backend/migrations/028_exercise_media_and_skills_api.sql create mode 100644 frontend/src/pages/ExerciseDetailPage.jsx create mode 100644 frontend/src/pages/ExerciseFormPage.jsx create mode 100644 frontend/src/pages/ExercisesListPage.jsx delete mode 100644 frontend/src/pages/ExercisesPage.jsx 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' : '') } > @@ -47,8 +56,7 @@ function Nav({ isAdmin }) { ) } -// Protected Route Component -function ProtectedRoute({ children }) { +function ProtectedLayout() { const { isAuthenticated, loading, user, logout } = useAuth() const handleLogout = () => { @@ -60,13 +68,15 @@ function ProtectedRoute({ children }) { if (loading) { return ( -
+
) @@ -80,17 +90,15 @@ function ProtectedRoute({ children }) { return ( <> - +
🥋 Shinkan
-
{children}
+
+ +
@@ -98,19 +106,20 @@ function ProtectedRoute({ children }) { ) } -// Public Route Component (redirect to dashboard if already logged in) function PublicRoute({ children }) { const { isAuthenticated, loading } = useAuth() if (loading) { return ( -
+
) @@ -122,7 +131,6 @@ function PublicRoute({ children }) { function AppRoutes() { return ( - {/* Public Routes */} - {/* Protected Routes */} - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> + }> + } /> + } /> + + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + - {/* Catch all - redirect to dashboard or login */} } /> ) diff --git a/frontend/src/components/admin/MaturityMatrixToolsAdmin.jsx b/frontend/src/components/admin/MaturityMatrixToolsAdmin.jsx index 31891cc..930a33c 100644 --- a/frontend/src/components/admin/MaturityMatrixToolsAdmin.jsx +++ b/frontend/src/components/admin/MaturityMatrixToolsAdmin.jsx @@ -55,6 +55,7 @@ export default function MaturityMatrixToolsAdmin() { const [stackWipe, setStackWipe] = useState(false) const [stackConfirmText, setStackConfirmText] = useState('') const [stackLoading, setStackLoading] = useState(false) + const [stackImportLoading, setStackImportLoading] = useState(false) useEffect(() => { let cancelled = false @@ -159,6 +160,45 @@ export default function MaturityMatrixToolsAdmin() { } } + async function handleImportStackFile(e) { + const file = e.target.files?.[0] + if (!file) return + setError('') + setMessage('') + setStackImportLoading(true) + try { + const data = JSON.parse(await file.text()) + if (data.kind !== 'shinkan.matrix_stack.v1') { + setError('Erwartet wird eine Datei mit kind: shinkan.matrix_stack.v1 (Komplett-Stack-Export).') + e.target.value = '' + return + } + if (stackWipe && stackConfirmText !== 'DELETE_MATURITY_STACK') { + setError('Vollständiges Ersetzen: Bestätigung exakt „DELETE_MATURITY_STACK“ eintragen (Superadmin).') + e.target.value = '' + return + } + const payload = { + ...data, + replace_all_maturity_models: stackWipe, + confirm_replace_all: stackWipe ? stackConfirmText : undefined + } + const res = await api.importMatrixStackBundle(payload) + const w = res.warnings || [] + setMessage( + `Stack-Import OK. Modell-Zuordnung: ${Object.keys(res.model_id_map || {}).length} Modell(e), Skills gemappt: ${Object.keys(res.skill_id_map || {}).length}.` + + (w.length ? ` ${w.length} Hinweis(e) (Konsole).` : '') + ) + if (w.length) console.warn('matrix_stack import warnings', w) + setModels(await api.listMaturityModels({})) + } catch (err) { + setError(err.message || String(err)) + } finally { + setStackImportLoading(false) + e.target.value = '' + } + } + async function handleImportFile(e) { const file = e.target.files?.[0] if (!file) return @@ -167,24 +207,9 @@ export default function MaturityMatrixToolsAdmin() { try { const data = JSON.parse(await file.text()) if (data.kind === 'shinkan.matrix_stack.v1') { - if (stackWipe && stackConfirmText !== 'DELETE_MATURITY_STACK') { - setError('Vollständiges Ersetzen: Bestätigung exakt „DELETE_MATURITY_STACK“ eintragen (Superadmin).') - e.target.value = '' - return - } - const payload = { - ...data, - replace_all_maturity_models: stackWipe, - confirm_replace_all: stackWipe ? stackConfirmText : undefined - } - const res = await api.importMatrixStackBundle(payload) - const w = res.warnings || [] - setMessage( - `Stack-Import OK. Modell-Zuordnung: ${Object.keys(res.model_id_map || {}).length} Modell(e).` + - (w.length ? ` ${w.length} Hinweis(e).` : '') - ) - if (w.length) console.warn('matrix_stack import warnings', w) - setModels(await api.listMaturityModels({})) + setError('Komplett-Stack: bitte die Datei im Abschnitt „Komplett-Stack“ importieren (eigenes Dateifeld).') + e.target.value = '' + return } else { const payload = { ...data, mode: importMode, import_bindings: importBindings } if (importMode === 'replace') { @@ -362,17 +387,17 @@ export default function MaturityMatrixToolsAdmin() {
-

Stack-Import (JSON-Datei unten)

+

Komplett-Stack importieren

- 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.