From cc51b0f08f95226472ac2929c077e0fb83c4a13b Mon Sep 17 00:00:00 2001
From: Lars
Date: Fri, 8 May 2026 11:44:29 +0200
Subject: [PATCH] feat(exercises): implement inline media support in exercise
content
- Added functionality for inline media references in exercise text using `{{exerciseMedia:id}}` syntax, which normalizes to a canonical `` element.
- Updated the frontend to utilize `ExerciseRichTextBlock` for rendering exercise content, allowing for embedded media display.
- Enhanced the Rich Text Editor to support inserting inline media placeholders.
- Version bump to 0.8.60 to reflect these changes in media handling and exercise content management.
---
.../MEDIA_ASSETS_AND_ARCHIVE_SPEC.md | 15 +--
.env.example | 2 +
backend/exercise_rich_text.py | 98 ++++++++++++++++++
backend/routers/exercises.py | 61 +++++++++++-
backend/tests/test_exercise_inline_post.py | 51 ++++++++++
backend/tests/test_exercise_rich_text.py | 43 ++++++++
backend/version.py | 14 ++-
docker-compose.yml | 2 +
.../src/components/ExerciseFullContent.jsx | 61 ++----------
.../src/components/ExerciseMediaEmbed.jsx | 43 ++++++++
frontend/src/components/ExercisePeekModal.jsx | 24 ++---
.../src/components/ExerciseRichTextBlock.jsx | 99 +++++++++++++++++++
frontend/src/components/RichTextEditor.jsx | 84 +++++++++++++++-
frontend/src/pages/ExerciseDetailPage.jsx | 69 +++----------
frontend/src/pages/ExerciseFormPage.jsx | 20 +++-
frontend/src/pages/ExercisesListPage.jsx | 19 ++--
.../src/utils/exerciseRichTextSanitize.js | 99 +++++++++++++++++++
17 files changed, 655 insertions(+), 149 deletions(-)
create mode 100644 backend/exercise_rich_text.py
create mode 100644 backend/tests/test_exercise_inline_post.py
create mode 100644 backend/tests/test_exercise_rich_text.py
create mode 100644 frontend/src/components/ExerciseMediaEmbed.jsx
create mode 100644 frontend/src/components/ExerciseRichTextBlock.jsx
create mode 100644 frontend/src/utils/exerciseRichTextSanitize.js
diff --git a/.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md b/.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md
index 4ca0d1d..46736e3 100644
--- a/.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md
+++ b/.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md
@@ -186,6 +186,7 @@ Das widerspricht **nicht** dem Papierkorb: wer kein Recht hat, ein Asset **globa
| Datum | Änderung |
|-------|----------|
+| 2026-05-08 | **0.8.60 §11:** Inline in Übungstexten (`{{exerciseMedia:id}}` / `data-shinkan-exercise-media`); Server-Normalisierung + Validierung; Client-Sanitize und zentraler Block-Renderer (`ExerciseRichTextBlock`); CREATE ohne bestehende `exercise_media` lehnt Platzhalter ab. |
| 2026-05-07 | **0.8.59:** Dokumentation — Aktiver Verein (Profil/Header/`effective_club_id`) für Plattform-Admin und UI-Dropdown synchron; kein fachliches Archiv-Schema-Change. |
| 2026-05-07 | **0.8.58:** Medien **`official`:** Lifecycle schwerpunktmäßig **Superadmin** (nicht Plattform-Admin); Bearbeitungsdialog Bibliothek für andere Rollen **Lesemodus**; Superadmin-Upload: Vereinskontext folgt aktiv gesetztem Verein / `effective_club_id`. |
| 2026-05-07 | **0.8.47–0.8.57 (Auszug):** Übung **`official`** nur Superadmin; Vereinsübungen mit File-Assets: **Copyright-Pflicht**; Speicherpfade **`library/`** mit Vereinsordner (Name+c{id}), Medienkind-Unterordner, Governance-Umzug bei Sichtbarkeit; Bibliothek-GET mit Filtern/Tags/Nutzungs-Anzeige; Bulk-Lifecycle/PATCH; Lesemodus/Kacheln; Konflikt **409** bei Upload-Dedupe vs. Papierkorb + UI-Reaktivierung. |
@@ -196,20 +197,19 @@ Das widerspricht **nicht** dem Papierkorb: wer kein Recht hat, ein Asset **globa
---
-## 11. Inline-Medien im Fließtext (Planung, Leitplanken)
+## 11. Inline-Medien im Fließtext (Umsetzung & Leitplanken)
-**Status:** nicht implementiert; verbindlich nur als **Richtschnur**, damit später **kein Big-Bang-Refactor** nötig ist.
+**Status:** Frontend-Renderer und API-Validierung/Normalisierung umgesetzt (App ≥ 0.8.60); verbindliche Leitplanken unten.
### 11.1 Ziel
- Medien (Player, Bild) sollen **an definierter Stelle** in Feldern wie Ablauf / Ziel / Notizen erscheinen können – zusätzlich oder statt reiner Zuordnung zu den Sektionen `ablauf` / `detail` / `trainer_hint`.
- **Keine zweite Sichtbarkeit:** Inline verweist immer auf dieselbe **Übungs-Medium-Zeile** (`exercise_media.id`) bzw. indirekt auf das gleiche Asset wie die Medienliste; **Lesen/Ausliefern** nur nach **bestehender** Übungs- + Medien-Governance (§4.1).
-### 11.2 Platzhalter-Konvention (Vorschlag für spätere Umsetzung)
+### 11.2 Platzhalter-Konvention (**festgelegt**)
-- Beim **Speichern** im Rich-Text: markierter Verweis, z. B.
- `data-shinkan-exercise-media=""` auf einem **neutralen** Element (`span`/`figure`), **oder** eine interne Kurzsyntax (`{{exerciseMedia:123}}`), die der Server beim Speichern in eine **kanonische** HTML-Form überführt.
-- **Final festlegen** beim Start der Implementierung (ein Format, nicht mehrere parallele).
+- **Kanonisches Markup nach Speichern:** ``.
+- **Kurzsyntax (Eingabe/Import):** `{{exerciseMedia:123}}` — Server normalisiert beim Speichern (PUT Übung / Varianten) in das kanonische `span`; **CREATE** ohne bestehende `exercise_media`-Zeilen verwirft Platzhalter mit **400**.
### 11.3 Rendering & Sicherheit
@@ -224,7 +224,8 @@ Das widerspricht **nicht** dem Papierkorb: wer kein Recht hat, ein Asset **globa
### 11.5 Wann umsetzen (Reihenfolge)
1. ~~**Erledigt (Basis):** Medien-Archiv, `media_assets`, Upload/Dedupe, Speicherpfad, Papierkorb, Bibliothek `/media`, Verknüpfung `from-asset`, Governance `official`/Copyright.~~
-2. **Als Nächstes (geplant):** Inline implementieren gemäß §11.1–11.4 — Trainer-Feedback/Content-Menge kann Priorität schärfen; technische Leitplanken hier sind verbindlich.
+2. **Umgesetzt (Stand 0.8.60):** Platzhalter/Kurzsyntax + zentraler Frontend-Render-Pfad + Server-Validierung gemäß §11.1–11.4; Editor-Einstieg „Medium einfügen“ in Übungsformular (Bearbeitungsmodus).
+ (Optional Feinschliff: komfortablerer Medien-Picker statt Prompt bei mehreren Medien.)
3. **Editor:** kein Zwang zum vollen Block-Editor vorab; **Platzhalter im bestehenden RTE** ist der vorgesehene schlanke Einstieg.
### 11.6 Refactor-Vermeidung (jetzt schon)
diff --git a/.env.example b/.env.example
index 31f5813..3c704b2 100644
--- a/.env.example
+++ b/.env.example
@@ -52,6 +52,8 @@ ENVIRONMENT=production
# Medien (Docker Compose): SHINKAN_MEDIA_HOST = Verzeichnis auf dem Host (Bind-Mount),
# MEDIA_ROOT = gleicher Pfad im Container (muss mit dem Mount-Ziel übereinstimmen — FastAPI).
+# Prod: Verzeichnis VOR erstem "docker compose up" anlegen (z. B. sudo mkdir -p …/prod).
+# Wenn Docker meldet "chown … operation not permitted": oft NAS/NFS oder Rechte — lokalen Pfad nutzen.
SHINKAN_MEDIA_HOST=/shinkan-media
MEDIA_ROOT=/app/media
diff --git a/backend/exercise_rich_text.py b/backend/exercise_rich_text.py
new file mode 100644
index 0000000..691facc
--- /dev/null
+++ b/backend/exercise_rich_text.py
@@ -0,0 +1,98 @@
+"""
+Übungs-Fließtext: Inline-Verweise auf exercise_media.id (MEDIA_ASSETS_AND_ARCHIVE_SPEC §11).
+
+- Kurzsyntax beim Speichern → kanonisches .
+- Verweise müssen für die betreffende Übung gültige exercise_media-Zeilen sein.
+"""
+
+from __future__ import annotations
+
+import re
+from typing import FrozenSet, Optional, Set
+
+from fastapi import HTTPException
+
+# {{exerciseMedia:123}} (Whitespace tolerant, case-insensitive Schlüssel)
+_BRACE_PATTERN = re.compile(r"\{\{\s*exerciseMedia\s*:\s*(\d+)\s*\}\}", re.IGNORECASE)
+# bereits gespeichertes Markup (einfache Anführungszeichen-varianten durch Regex abgedeckt)
+_DATA_ATTR_PATTERN = re.compile(r'data-shinkan-exercise-media\s*=\s*["\']?(\d+)["\']?', re.IGNORECASE)
+
+RICH_HTML_EXERCISE_FIELDS: FrozenSet[str] = frozenset(
+ {"summary", "goal", "execution", "preparation", "trainer_notes"}
+)
+
+
+def normalize_inline_exercise_media_markup(html: Optional[str]) -> Optional[str]:
+ """Wandelt {{exerciseMedia:id}} in kanonisches span mit data-shinkan-exercise-media."""
+
+ if html is None:
+ return None
+ if not isinstance(html, str):
+ html = str(html)
+ stripped = html.strip()
+ if not stripped:
+ return html
+
+ def _repl(match: re.Match) -> str:
+ mid = int(match.group(1))
+ return f''
+
+ return _BRACE_PATTERN.sub(_repl, html)
+
+
+def collect_inline_exercise_media_ids(html: Optional[str]) -> Set[int]:
+ """Sammelt alle referenzierten exercise_media.ids aus Kurzsyntax und kanonischem Span."""
+ if html is None or not isinstance(html, str):
+ return set()
+ if not html.strip():
+ return set()
+ ids: Set[int] = set()
+ ids.update(int(m) for m in _BRACE_PATTERN.findall(html))
+ ids.update(int(m) for m in _DATA_ATTR_PATTERN.findall(html))
+ return ids
+
+
+def assert_no_inline_media_references_on_create(ids: Set[int]) -> None:
+ """Neue Übung hat noch keine exercise_media-Zeilen — Platzhalter verboten."""
+
+ if not ids:
+ return
+ raise HTTPException(
+ status_code=400,
+ detail={
+ "code": "INLINE_EXERCISE_MEDIA_ON_CREATE",
+ "message": (
+ "Medienverweise im Fließtext sind beim ersten Anlegen der Übung nicht möglich. "
+ "Bitte Übung ohne Platzhalter speichern, Medien hochladen oder verknüpfen "
+ "und die Verweise dann bearbeiten ({{exerciseMedia:id}} oder „Medium einfügen“)."
+ ),
+ "invalid_exercise_media_ids": sorted(ids),
+ },
+ )
+
+
+def validate_inline_exercise_media_ids_for_exercise(cur, exercise_id: int, ids: Set[int]) -> None:
+ """Prüft, dass jede genannte exercise_media.id zu dieser Übung gehört."""
+
+ if not ids:
+ return
+ sid = sorted(ids)
+ ph = ",".join(["%s"] * len(sid))
+ cur.execute(
+ f"SELECT id FROM exercise_media WHERE exercise_id = %s AND id IN ({ph})",
+ (exercise_id, *sid),
+ )
+ found = set()
+ for row in cur.fetchall():
+ rid = row["id"] if isinstance(row, dict) else row[0]
+ found.add(int(rid))
+ missing = ids - found
+ if missing:
+ raise HTTPException(
+ status_code=400,
+ detail={
+ "code": "INLINE_EXERCISE_MEDIA_INVALID",
+ "message": "Ein oder mehrere eingebettete Medien-Verweise gehören nicht zu dieser Übung.",
+ "invalid_exercise_media_ids": sorted(missing),
+ },
+ )
diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py
index 22865e5..5551519 100644
--- a/backend/routers/exercises.py
+++ b/backend/routers/exercises.py
@@ -29,6 +29,13 @@ from club_tenancy import (
)
from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible, library_content_visibility_sql
from media_storage import get_effective_media_root, library_storage_key, path_under_media_root
+from exercise_rich_text import (
+ RICH_HTML_EXERCISE_FIELDS,
+ assert_no_inline_media_references_on_create,
+ collect_inline_exercise_media_ids,
+ normalize_inline_exercise_media_markup,
+ validate_inline_exercise_media_ids_for_exercise,
+)
logger = logging.getLogger(__name__)
@@ -1812,6 +1819,16 @@ def create_exercise(
if body.visibility == "club" and club_id is None:
club_id = tenant.effective_club_id
+ # §11 Inline-Medien: Kurzsyntax → kanonisches Markup; Verweise erst nach Medien-Anlage möglich
+ create_ids: set[int] = set()
+ for fld in sorted(RICH_HTML_EXERCISE_FIELDS):
+ raw_html = getattr(body, fld, None)
+ if raw_html:
+ normed = normalize_inline_exercise_media_markup(raw_html)
+ setattr(body, fld, normed)
+ create_ids |= collect_inline_exercise_media_ids(normed or "")
+ assert_no_inline_media_references_on_create(create_ids)
+
with get_db() as conn:
cur = get_cursor(conn)
assert_valid_governance_visibility(
@@ -1868,16 +1885,25 @@ def update_exercise(
cur = get_cursor(conn)
cur.execute(
- "SELECT created_by, visibility, club_id FROM exercises WHERE id = %s",
+ f"""SELECT created_by, visibility, club_id,
+ {", ".join(sorted(RICH_HTML_EXERCISE_FIELDS))}
+ FROM exercises WHERE id = %s""",
(exercise_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
+ rd_full = r2d(row)
+ rich_row = {fld: rd_full.get(fld) for fld in RICH_HTML_EXERCISE_FIELDS}
+
_assert_can_edit_exercise(cur, exercise_id, tenant)
- rd = r2d(row)
+ rd = {
+ "created_by": rd_full.get("created_by"),
+ "visibility": rd_full.get("visibility"),
+ "club_id": rd_full.get("club_id"),
+ }
ex_vis = (rd.get("visibility") or "private").strip().lower()
ex_cid = rd.get("club_id")
if ex_cid is not None:
@@ -1888,6 +1914,23 @@ def update_exercise(
promote_media_flag = raw_promo is True
default_official_copy = data.pop("default_official_media_copyright", None)
+ merged_rich = {fld: rich_row.get(fld) for fld in RICH_HTML_EXERCISE_FIELDS}
+ for fld in RICH_HTML_EXERCISE_FIELDS:
+ if fld not in data:
+ continue
+ raw_v = data[fld]
+ if raw_v is None:
+ merged_rich[fld] = None
+ continue
+ if isinstance(raw_v, str):
+ nv = normalize_inline_exercise_media_markup(raw_v)
+ data[fld] = nv
+ merged_rich[fld] = nv
+ inline_union: set[int] = set()
+ for val in merged_rich.values():
+ inline_union |= collect_inline_exercise_media_ids(val if isinstance(val, str) else None)
+ validate_inline_exercise_media_ids_for_exercise(cur, exercise_id, inline_union)
+
next_vis = ex_vis
if "visibility" in data and data["visibility"] is not None:
v_raw = str(data["visibility"]).strip().lower()
@@ -2064,7 +2107,11 @@ def create_exercise_variant(
seq = cur.fetchone()["n"]
desc = (body.description or "").strip() or None
- exec_ch = (body.execution_changes or "").strip() or None
+ exec_ch_raw = (body.execution_changes or "").strip() or None
+ exec_ch = normalize_inline_exercise_media_markup(exec_ch_raw) if exec_ch_raw else None
+ if exec_ch:
+ v_ids = collect_inline_exercise_media_ids(exec_ch)
+ validate_inline_exercise_media_ids_for_exercise(cur, exercise_id, v_ids)
diff = (body.difficulty_adjustment or "").strip() or None
cur.execute(
@@ -2118,7 +2165,13 @@ def update_exercise_variant(
if "description" in data:
old["description"] = (data["description"] or "").strip() or None
if "execution_changes" in data:
- old["execution_changes"] = (data["execution_changes"] or "").strip() or None
+ ec_raw = (data["execution_changes"] or "").strip() or None
+ ec_norm = normalize_inline_exercise_media_markup(ec_raw) if ec_raw else None
+ if ec_norm:
+ validate_inline_exercise_media_ids_for_exercise(
+ cur, exercise_id, collect_inline_exercise_media_ids(ec_norm)
+ )
+ old["execution_changes"] = ec_norm
if "duration_min" in data:
old["duration_min"] = data["duration_min"]
if "duration_max" in data:
diff --git a/backend/tests/test_exercise_inline_post.py b/backend/tests/test_exercise_inline_post.py
new file mode 100644
index 0000000..36ce2a3
--- /dev/null
+++ b/backend/tests/test_exercise_inline_post.py
@@ -0,0 +1,51 @@
+"""POST /api/exercises: keine Inline-Medien-Platzhalter beim ersten Anlegen (§11)."""
+
+from __future__ import annotations
+
+import os
+
+import pytest
+from fastapi.testclient import TestClient
+
+os.environ.setdefault("SKIP_DB_MIGRATE", "1")
+
+from auth import require_auth
+from main import app
+from tenant_context import TenantContext, get_tenant_context
+
+
+@pytest.fixture
+def client() -> TestClient:
+ return TestClient(app)
+
+
+@pytest.fixture(autouse=True)
+def _clear_overrides() -> None:
+ yield
+ app.dependency_overrides.pop(require_auth, None)
+ app.dependency_overrides.pop(get_tenant_context, None)
+
+
+def test_post_exercise_rejects_inline_media_placeholder(client: TestClient) -> None:
+ app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
+ app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
+ profile_id=1,
+ global_role="trainer",
+ effective_club_id=5,
+ club_ids=frozenset({5}),
+ memberships=[],
+ )
+ r = client.post(
+ "/api/exercises",
+ json={
+ "title": "Mit Inline",
+ "goal": "Hallo {{exerciseMedia:7}}
",
+ "execution": "Schritt
",
+ "visibility": "private",
+ "status": "draft",
+ },
+ headers={"X-Auth-Token": "x"},
+ )
+ assert r.status_code == 400
+ j = r.json()
+ assert j.get("detail", {}).get("code") == "INLINE_EXERCISE_MEDIA_ON_CREATE"
diff --git a/backend/tests/test_exercise_rich_text.py b/backend/tests/test_exercise_rich_text.py
new file mode 100644
index 0000000..3ae82e9
--- /dev/null
+++ b/backend/tests/test_exercise_rich_text.py
@@ -0,0 +1,43 @@
+"""exercise_rich_text: §11 Normalisierung und ID-Sammlung."""
+
+from __future__ import annotations
+
+import pytest
+from fastapi import HTTPException
+from unittest.mock import MagicMock
+
+from exercise_rich_text import (
+ assert_no_inline_media_references_on_create,
+ collect_inline_exercise_media_ids,
+ normalize_inline_exercise_media_markup,
+ validate_inline_exercise_media_ids_for_exercise,
+)
+
+
+def test_normalize_curly_to_span() -> None:
+ s = 'Vor {{exerciseMedia: 42 }} nach
'
+ out = normalize_inline_exercise_media_markup(s)
+ assert 'data-shinkan-exercise-media="42"' in out
+ assert "{{" not in out
+
+
+def test_collect_merges_braces_and_data_attr() -> None:
+ html = '{{ExercisemEDIA: 1}}\n'
+ assert collect_inline_exercise_media_ids(html) == {1, 2}
+
+
+def test_assert_no_inline_on_create_raises() -> None:
+ with pytest.raises(HTTPException) as ei:
+ assert_no_inline_media_references_on_create({5})
+ assert ei.value.status_code == 400
+ body = ei.value.detail
+ assert isinstance(body, dict)
+ assert body["code"] == "INLINE_EXERCISE_MEDIA_ON_CREATE"
+
+
+def test_validate_ids_sql_mock() -> None:
+ mock_cur = MagicMock()
+ mock_cur.fetchall.return_value = [{"id": 1}]
+ with pytest.raises(HTTPException) as ei:
+ validate_inline_exercise_media_ids_for_exercise(mock_cur, 100, {1, 99})
+ assert ei.value.detail["invalid_exercise_media_ids"] == [99]
diff --git a/backend/version.py b/backend/version.py
index 2b819a6..76cce51 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,7 +1,7 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.59"
-BUILD_DATE = "2026-05-07"
+APP_VERSION = "0.8.60"
+BUILD_DATE = "2026-05-08"
DB_SCHEMA_VERSION = "20260508049"
MODULE_VERSIONS = {
@@ -17,7 +17,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
- "exercises": "2.18.0", # Vereins-Übung: Copyright-Pflicht File-Assets; official nur Superadmin (Governance)
+ "exercises": "2.19.0", # Inline-Medien §11: Fließtext-Platzhalter exercise_media.id, Normalisierung/Validierung; CREATE ohne Platzhalter
"training_units": "0.2.0",
"training_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
@@ -29,6 +29,14 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.60",
+ "date": "2026-05-08",
+ "changes": [
+ "Inline-Medien im Übungs-Fließtext (MEDIA_SPEC §11): {{exerciseMedia:id}} → kanonisches span; nur exercise_media dieser Übung; create ohne Platzhalter",
+ "Frontend: ExerciseRichTextBlock mit Allowlist-Sanitize + Embed; Toolbar „Bild/Video im Text“ im RichTextEditor wenn Medien an der Übung; Detail/Katalog/Liste konsistent",
+ ],
+ },
{
"version": "0.8.59",
"date": "2026-05-07",
diff --git a/docker-compose.yml b/docker-compose.yml
index 4231662..0c1f165 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -54,6 +54,8 @@ services:
MEDIAWIKI_CATEGORY_MODELS: "${MEDIAWIKI_CATEGORY_MODELS:-Reifegradmodelle}"
# Medien: Host-Pfad SHINKAN_MEDIA_HOST (in .env), Ziel im Container MEDIA_ROOT.
MEDIA_ROOT: "${MEDIA_ROOT:-/app/media}"
+ # Bind-Mount: Verzeichnis muss auf dem Host existieren und chown für den Docker-Daemon
+ # zulassen (lokale Platte). Bei NFS/SMB oft "chown … operation not permitted" → anderen Pfad.
volumes:
- ${SHINKAN_MEDIA_HOST:-/shinkan-media}:${MEDIA_ROOT:-/app/media}
ports:
diff --git a/frontend/src/components/ExerciseFullContent.jsx b/frontend/src/components/ExerciseFullContent.jsx
index cd94b9a..0e1b107 100644
--- a/frontend/src/components/ExerciseFullContent.jsx
+++ b/frontend/src/components/ExerciseFullContent.jsx
@@ -3,52 +3,8 @@
*/
import React from 'react'
import { Link } from 'react-router-dom'
-import { sanitizeTrainerHtml } from '../utils/htmlUtils'
-import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
-
-function HtmlBlock({ html, className = '' }) {
- if (!html || !String(html).trim()) return null
- const safe = sanitizeTrainerHtml(html)
- return (
-
- )
-}
-
-function MediaBlock({ media, exerciseId }) {
- if (media.embed_url) {
- return (
-
- )
- }
- const src = resolveExerciseMediaFileUrl(exerciseId, media)
- if (!src) return null
- if (media.media_type === 'image' || (media.mime_type && media.mime_type.startsWith('image/'))) {
- return (
-
- )
- }
- if (media.media_type === 'video' || (media.mime_type && media.mime_type.startsWith('video/'))) {
- return
- }
- return (
-
- {media.title || media.original_filename || 'Datei öffnen'}
-
- )
-}
+import ExerciseRichTextBlock from './ExerciseRichTextBlock'
+import ExerciseMediaEmbed from './ExerciseMediaEmbed'
function TagRow({ exercise }) {
const tags = []
@@ -112,6 +68,7 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
}
if (!exercise) return null
+ const resolvedId = exercise.id ?? exerciseId
const meta = metaParts(exercise)
const visibleMedia = (exercise.media || []).filter((m) => {
const lc = String(m.asset_lifecycle_state || 'active').toLowerCase()
@@ -132,13 +89,13 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
Kurzbeschreibung
-
+
)}
{exercise.goal && (
)}
{(exercise.equipment || []).length > 0 && (
@@ -156,13 +113,13 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
{exercise.preparation && (
)}
{exercise.execution && (
)}
{visibleMedia.length > 0 && (
@@ -179,7 +136,7 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
)}
{m.description && {m.description}
}
-
+
))}
@@ -189,7 +146,7 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
Hinweise Trainer (Katalog)
-
+
)}
{exerciseId != null && (
diff --git a/frontend/src/components/ExerciseMediaEmbed.jsx b/frontend/src/components/ExerciseMediaEmbed.jsx
new file mode 100644
index 0000000..e010fa3
--- /dev/null
+++ b/frontend/src/components/ExerciseMediaEmbed.jsx
@@ -0,0 +1,43 @@
+import React from 'react'
+import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
+
+/**
+ * Ein ausgeliefertes exercise_media für Übungslisten (Liste + Inline gleiche Darstellung).
+ * @param {{ media: object, exerciseId: number }} props
+ */
+export default function ExerciseMediaEmbed({ exerciseId, media }) {
+ if (!media || exerciseId == null) return null
+ if (media.embed_url) {
+ return (
+
+ )
+ }
+ const src = resolveExerciseMediaFileUrl(exerciseId, media)
+ if (!src) return null
+ if (media.media_type === 'image' || (media.mime_type && media.mime_type.startsWith('image/'))) {
+ return (
+
+ )
+ }
+ if (media.media_type === 'video' || (media.mime_type && media.mime_type.startsWith('video/'))) {
+ return
+ }
+ return (
+
+ {media.title || media.original_filename || 'Datei öffnen'}
+
+ )
+}
diff --git a/frontend/src/components/ExercisePeekModal.jsx b/frontend/src/components/ExercisePeekModal.jsx
index 56a1c02..208cac4 100644
--- a/frontend/src/components/ExercisePeekModal.jsx
+++ b/frontend/src/components/ExercisePeekModal.jsx
@@ -4,15 +4,7 @@
import React, { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
-import { sanitizeTrainerHtml } from '../utils/htmlUtils'
-
-function HtmlBlock({ html, className = '' }) {
- if (!html || !String(html).trim()) return null
- const safe = sanitizeTrainerHtml(html)
- return (
-
- )
-}
+import ExerciseRichTextBlock from './ExerciseRichTextBlock'
function TagMini({ exercise }) {
const parts = []
@@ -129,7 +121,7 @@ export default function ExercisePeekModal({
{variant.description ? (
-
+
) : null}
{variant.execution_changes ? (
@@ -137,14 +129,14 @@ export default function ExercisePeekModal({
Durchführung (Variante)
-
+
) : null}
) : null}
{exercise.summary && (
-
+
)}
@@ -154,25 +146,25 @@ export default function ExercisePeekModal({
{exercise.goal && (
<>
Ziel
-
+
>
)}
{exercise.preparation && (
<>
Vorbereitung
-
+
>
)}
{exercise.execution && (
<>
Ablauf
-
+
>
)}
{exercise.trainer_notes && (
<>
Trainer-Hinweise
-
+
>
)}
>
diff --git a/frontend/src/components/ExerciseRichTextBlock.jsx b/frontend/src/components/ExerciseRichTextBlock.jsx
new file mode 100644
index 0000000..8845292
--- /dev/null
+++ b/frontend/src/components/ExerciseRichTextBlock.jsx
@@ -0,0 +1,99 @@
+import React, { useMemo } from 'react'
+import { sanitizeExerciseRichDisplayHtml } from '../utils/exerciseRichTextSanitize'
+import ExerciseMediaEmbed from './ExerciseMediaEmbed'
+
+function isTrashHidden(m) {
+ return String(m?.asset_lifecycle_state || 'active').toLowerCase() === 'trash_hidden'
+}
+
+function buildVisibleMediaMap(mediaList) {
+ const map = new Map()
+ for (const m of mediaList || []) {
+ if (!m || m.id == null || isTrashHidden(m)) continue
+ map.set(Number(m.id), m)
+ }
+ return map
+}
+
+function domToReactNodes(node, exerciseId, mediaById, path) {
+ if (node.nodeType === Node.TEXT_NODE) {
+ const t = node.textContent
+ return t ? t : null
+ }
+ if (node.nodeType !== Node.ELEMENT_NODE) return null
+
+ const el = node
+ const tag = el.tagName.toLowerCase()
+ const key = path.join('.')
+
+ if (tag === 'span' && el.getAttribute('data-shinkan-exercise-media')) {
+ const raw = el.getAttribute('data-shinkan-exercise-media')
+ const mid = parseInt(raw, 10)
+ if (!Number.isFinite(mid) || mid < 1) {
+ return (
+
+ [Ungültiger Medienverweis]
+
+ )
+ }
+ const media = mediaById.get(mid)
+ if (!media) {
+ return (
+
+ [Medium nicht verfügbar]
+
+ )
+ }
+ const lc = String(media.asset_lifecycle_state || 'active').toLowerCase()
+ return (
+
+ {lc === 'trash_soft' && (
+
+ Dieses Medium ist im Papierkorb.
+
+ )}
+
+
+ )
+ }
+
+ const children = []
+ const childNodes = Array.from(el.childNodes)
+ childNodes.forEach((ch, i) => {
+ const sub = domToReactNodes(ch, exerciseId, mediaById, [...path, String(i)])
+ if (sub != null && sub !== false) children.push(sub)
+ })
+
+ const props = { key }
+ if (tag === 'a' && el.getAttribute('href')) {
+ props.href = el.getAttribute('href')
+ props.target = '_blank'
+ props.rel = 'noreferrer'
+ }
+ return React.createElement(tag, props, children.length ? children : null)
+}
+
+/**
+ * Zentraler Anzeige-Pfad für Übungs-Rich-Text inkl. §11 Inline-Medien.
+ * @param {{ html?: string|null, exerciseId?: number|null, media?: object[]|null, className?: string }} props
+ */
+export default function ExerciseRichTextBlock({ html, exerciseId, media, className = '' }) {
+ const safe = useMemo(() => sanitizeExerciseRichDisplayHtml(html), [html])
+ const mediaById = useMemo(() => buildVisibleMediaMap(media), [media])
+
+ const body = useMemo(() => {
+ if (!safe.trim()) return null
+ const tpl = document.createElement('template')
+ tpl.innerHTML = safe
+ const nodes = []
+ Array.from(tpl.content.childNodes).forEach((ch, i) => {
+ const r = domToReactNodes(ch, exerciseId, mediaById, [String(i)])
+ if (r != null) nodes.push(r)
+ })
+ return nodes
+ }, [safe, exerciseId, mediaById])
+
+ if (!body || body.length === 0) return null
+
+ return {body}
+}
diff --git a/frontend/src/components/RichTextEditor.jsx b/frontend/src/components/RichTextEditor.jsx
index 5a83d93..bffb0b4 100644
--- a/frontend/src/components/RichTextEditor.jsx
+++ b/frontend/src/components/RichTextEditor.jsx
@@ -45,10 +45,50 @@ function normalText() {
formatBlock('p')
}
+function insertExerciseMediaPlaceholder(editorEl, mediaId) {
+ if (!editorEl || mediaId == null) return
+ const sid = parseInt(String(mediaId), 10)
+ if (!Number.isFinite(sid) || sid < 1) return
+ editorEl.focus()
+ const sel = window.getSelection()
+ if (!sel) return
+ let range = null
+ if (sel?.rangeCount) {
+ try {
+ const r0 = sel.getRangeAt(0)
+ if (editorEl.contains(r0.commonAncestorContainer)) range = r0
+ } catch {
+ /* ignore */
+ }
+ }
+ if (!range) {
+ range = document.createRange()
+ range.selectNodeContents(editorEl)
+ range.collapse(false)
+ }
+ const span = document.createElement('span')
+ span.setAttribute('data-shinkan-exercise-media', String(sid))
+ span.className = 'shinkan-inline-media'
+ span.appendChild(document.createTextNode('\u2060'))
+ range.deleteContents()
+ range.insertNode(span)
+ range.setStartAfter(span)
+ range.collapse(true)
+ sel.removeAllRanges()
+ sel.addRange(range)
+}
+
/**
* Leichter WYSIWYG (contenteditable). Wert kommt von außen zuverlässig ins DOM (Edit-Modus).
+ * @param {{ id: number, label: string }[]} [insertExerciseMediaSlots] — §11 Verweise auf exercise_media.id
*/
-export default function RichTextEditor({ value, onChange, placeholder, minHeight = '140px' }) {
+export default function RichTextEditor({
+ value,
+ onChange,
+ placeholder,
+ minHeight = '140px',
+ insertExerciseMediaSlots,
+}) {
const ref = useRef(null)
const [focused, setFocused] = useState(false)
@@ -98,6 +138,38 @@ export default function RichTextEditor({ value, onChange, placeholder, minHeight
}
}
+ const showMediaPick = Array.isArray(insertExerciseMediaSlots) && insertExerciseMediaSlots.length > 0
+
+ const onInsertExerciseMediaClick = (e) => {
+ e.preventDefault()
+ e.stopPropagation()
+ const el = ref.current
+ if (!el || !insertExerciseMediaSlots?.length) return
+ let choice = ''
+ if (insertExerciseMediaSlots.length === 1) {
+ choice = String(insertExerciseMediaSlots[0].id)
+ } else {
+ choice = window.prompt(
+ `Medium-ID eingeben oder aus Liste:\n${insertExerciseMediaSlots
+ .slice(0, 30)
+ .map((s) => `${s.id}: ${s.label}`)
+ .join('\n')}`,
+ '',
+ )
+ }
+ const idParsed = parseInt(String(choice).trim(), 10)
+ if (!Number.isFinite(idParsed)) return
+ if (!insertExerciseMediaSlots.some((s) => Number(s.id) === idParsed)) {
+ alert('Diese Übungs-ID ist nicht in der Medienliste.')
+ return
+ }
+ const saved = saveSelectionInside(el)
+ el.focus()
+ restoreSelection(saved)
+ insertExerciseMediaPlaceholder(el, idParsed)
+ sync()
+ }
+
return (
@@ -133,6 +205,16 @@ export default function RichTextEditor({ value, onChange, placeholder, minHeight
+ {showMediaPick ? (
+
+ ) : null}
- )
-}
-
-function MediaBlock({ media, exerciseId }) {
- if (media.embed_url) {
- return (
-
- )
- }
- const src = resolveExerciseMediaFileUrl(exerciseId, media)
- if (!src) return null
- if (media.media_type === 'image' || (media.mime_type && media.mime_type.startsWith('image/'))) {
- return (
-

- )
- }
- if (media.media_type === 'video' || (media.mime_type && media.mime_type.startsWith('video/'))) {
- return
- }
- return (
-
- {media.title || media.original_filename || 'Datei öffnen'}
-
- )
-}
-
function TagRow({ exercise }) {
const tags = []
;(exercise.focus_areas || []).forEach((f) => {
@@ -171,7 +124,7 @@ function ExerciseDetailPage() {
{exercise.title}
{exercise.summary && (
-
+
)}
@@ -186,7 +139,7 @@ function ExerciseDetailPage() {
{exercise.goal && (
)}
@@ -204,14 +157,14 @@ function ExerciseDetailPage() {
{exercise.preparation && (
)}
{exercise.execution && (
)}
@@ -227,7 +180,7 @@ function ExerciseDetailPage() {
)}
{m.description &&
{m.description}
}
-
+
))}
@@ -236,7 +189,7 @@ function ExerciseDetailPage() {
{exercise.trainer_notes && (
)}
@@ -307,7 +260,11 @@ function ExerciseDetailPage() {
{v.description &&
{v.description}
}
{v.execution_changes && (
-
+
)}
diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx
index b5e99bf..884ebc9 100644
--- a/frontend/src/pages/ExerciseFormPage.jsx
+++ b/frontend/src/pages/ExerciseFormPage.jsx
@@ -169,7 +169,7 @@ function buildVariantPayloadFromRow(row) {
}
/** Gemeinsame Felder für „Variante bearbeiten“ und „Neue Variante“. */
-function ExerciseVariantFields({ row, onPatch, prerequisiteOthers, rteMinHeight = '110px' }) {
+function ExerciseVariantFields({ row, onPatch, prerequisiteOthers, rteMinHeight = '110px', exerciseMediaInsertSlots }) {
return (
<>
@@ -198,6 +198,7 @@ function ExerciseVariantFields({ row, onPatch, prerequisiteOthers, rteMinHeight
onChange={(html) => onPatch({ execution_changes: html })}
placeholder="Was unterscheidet diese Variante? (Listen über Symbolleiste)"
minHeight={rteMinHeight}
+ insertExerciseMediaSlots={exerciseMediaInsertSlots}
/>
@@ -459,6 +460,16 @@ function ExerciseFormPage() {
const [archiveError, setArchiveError] = useState(null)
const [mediaPreview, setMediaPreview] = useState(null)
+ const exerciseMediaInsertSlots = useMemo(() => {
+ if (!isEdit) return []
+ return (mediaList || [])
+ .filter((m) => m?.id != null)
+ .map((m) => ({
+ id: m.id,
+ label: (m.title && String(m.title).trim()) || m.original_filename || `Medium #${m.id}`,
+ }))
+ }, [isEdit, mediaList])
+
useEffect(() => {
const next = {}
for (const m of mediaList) {
@@ -1040,6 +1051,7 @@ function ExerciseFormPage() {
onChange={(html) => updateFormField('summary', html)}
placeholder="Kurzbeschreibung (optional)"
minHeight="80px"
+ insertExerciseMediaSlots={exerciseMediaInsertSlots}
/>
@@ -1050,6 +1062,7 @@ function ExerciseFormPage() {
onChange={(html) => updateFormField('goal', html)}
placeholder="Trainingsziel"
minHeight="120px"
+ insertExerciseMediaSlots={exerciseMediaInsertSlots}
/>
@@ -1060,6 +1073,7 @@ function ExerciseFormPage() {
onChange={(html) => updateFormField('execution', html)}
placeholder="Ablauf Schritt für Schritt"
minHeight="180px"
+ insertExerciseMediaSlots={exerciseMediaInsertSlots}
/>
@@ -1070,6 +1084,7 @@ function ExerciseFormPage() {
onChange={(html) => updateFormField('preparation', html)}
placeholder="Matten, Raum, …"
minHeight="100px"
+ insertExerciseMediaSlots={exerciseMediaInsertSlots}
/>
@@ -1080,6 +1095,7 @@ function ExerciseFormPage() {
onChange={(html) => updateFormField('trainer_notes', html)}
placeholder="Sicherheit, Varianten-Hinweise, …"
minHeight="100px"
+ insertExerciseMediaSlots={exerciseMediaInsertSlots}
/>
@@ -1374,6 +1390,7 @@ function ExerciseFormPage() {
onPatch={(patch) => setVariantDraft((d) => ({ ...d, ...patch }))}
prerequisiteOthers={variants}
rteMinHeight="110px"
+ exerciseMediaInsertSlots={exerciseMediaInsertSlots}
/>