Inline Medien #24
|
|
@ -186,6 +186,7 @@ Das widerspricht **nicht** dem Papierkorb: wer kein Recht hat, ein Asset **globa
|
||||||
|
|
||||||
| Datum | Änderung |
|
| 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.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.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. |
|
| 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
|
### 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`.
|
- 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).
|
- **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.
|
- **Kanonisches Markup nach Speichern:** `<span data-shinkan-exercise-media="<numerische exercise_media.id>" class="shinkan-inline-media"></span>`.
|
||||||
`data-shinkan-exercise-media="<numerische exercise_media.id>"` auf einem **neutralen** Element (`span`/`figure`), **oder** eine interne Kurzsyntax (`{{exerciseMedia:123}}`), die der Server beim Speichern in eine **kanonische** HTML-Form überführt.
|
- **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**.
|
||||||
- **Final festlegen** beim Start der Implementierung (ein Format, nicht mehrere parallele).
|
|
||||||
|
|
||||||
### 11.3 Rendering & Sicherheit
|
### 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)
|
### 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.~~
|
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.
|
3. **Editor:** kein Zwang zum vollen Block-Editor vorab; **Platzhalter im bestehenden RTE** ist der vorgesehene schlanke Einstieg.
|
||||||
|
|
||||||
### 11.6 Refactor-Vermeidung (jetzt schon)
|
### 11.6 Refactor-Vermeidung (jetzt schon)
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@ ENVIRONMENT=production
|
||||||
|
|
||||||
# Medien (Docker Compose): SHINKAN_MEDIA_HOST = Verzeichnis auf dem Host (Bind-Mount),
|
# 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).
|
# 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
|
SHINKAN_MEDIA_HOST=/shinkan-media
|
||||||
MEDIA_ROOT=/app/media
|
MEDIA_ROOT=/app/media
|
||||||
|
|
||||||
|
|
|
||||||
98
backend/exercise_rich_text.py
Normal file
98
backend/exercise_rich_text.py
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
"""
|
||||||
|
Übungs-Fließtext: Inline-Verweise auf exercise_media.id (MEDIA_ASSETS_AND_ARCHIVE_SPEC §11).
|
||||||
|
|
||||||
|
- Kurzsyntax beim Speichern → kanonisches <span data-shinkan-exercise-media="…">.
|
||||||
|
- 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'<span data-shinkan-exercise-media="{mid}" class="shinkan-inline-media"></span>'
|
||||||
|
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
@ -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 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 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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -1812,6 +1819,16 @@ def create_exercise(
|
||||||
if body.visibility == "club" and club_id is None:
|
if body.visibility == "club" and club_id is None:
|
||||||
club_id = tenant.effective_club_id
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
assert_valid_governance_visibility(
|
assert_valid_governance_visibility(
|
||||||
|
|
@ -1868,16 +1885,25 @@ def update_exercise(
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
cur.execute(
|
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,),
|
(exercise_id,),
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
|
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)
|
_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_vis = (rd.get("visibility") or "private").strip().lower()
|
||||||
ex_cid = rd.get("club_id")
|
ex_cid = rd.get("club_id")
|
||||||
if ex_cid is not None:
|
if ex_cid is not None:
|
||||||
|
|
@ -1888,6 +1914,23 @@ def update_exercise(
|
||||||
promote_media_flag = raw_promo is True
|
promote_media_flag = raw_promo is True
|
||||||
default_official_copy = data.pop("default_official_media_copyright", None)
|
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
|
next_vis = ex_vis
|
||||||
if "visibility" in data and data["visibility"] is not None:
|
if "visibility" in data and data["visibility"] is not None:
|
||||||
v_raw = str(data["visibility"]).strip().lower()
|
v_raw = str(data["visibility"]).strip().lower()
|
||||||
|
|
@ -2064,7 +2107,11 @@ def create_exercise_variant(
|
||||||
seq = cur.fetchone()["n"]
|
seq = cur.fetchone()["n"]
|
||||||
|
|
||||||
desc = (body.description or "").strip() or None
|
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
|
diff = (body.difficulty_adjustment or "").strip() or None
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|
@ -2118,7 +2165,13 @@ def update_exercise_variant(
|
||||||
if "description" in data:
|
if "description" in data:
|
||||||
old["description"] = (data["description"] or "").strip() or None
|
old["description"] = (data["description"] or "").strip() or None
|
||||||
if "execution_changes" in data:
|
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:
|
if "duration_min" in data:
|
||||||
old["duration_min"] = data["duration_min"]
|
old["duration_min"] = data["duration_min"]
|
||||||
if "duration_max" in data:
|
if "duration_max" in data:
|
||||||
|
|
|
||||||
51
backend/tests/test_exercise_inline_post.py
Normal file
51
backend/tests/test_exercise_inline_post.py
Normal file
|
|
@ -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": "<p>Hallo {{exerciseMedia:7}}</p>",
|
||||||
|
"execution": "<p>Schritt</p>",
|
||||||
|
"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"
|
||||||
43
backend/tests/test_exercise_rich_text.py
Normal file
43
backend/tests/test_exercise_rich_text.py
Normal file
|
|
@ -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 = '<p>Vor {{exerciseMedia: 42 }} nach</p>'
|
||||||
|
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<span data-shinkan-exercise-media="2"></span>'
|
||||||
|
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]
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.59"
|
APP_VERSION = "0.8.60"
|
||||||
BUILD_DATE = "2026-05-07"
|
BUILD_DATE = "2026-05-08"
|
||||||
DB_SCHEMA_VERSION = "20260508049"
|
DB_SCHEMA_VERSION = "20260508049"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
|
|
@ -17,7 +17,7 @@ MODULE_VERSIONS = {
|
||||||
"groups": "0.1.0",
|
"groups": "0.1.0",
|
||||||
"skills": "0.1.0",
|
"skills": "0.1.0",
|
||||||
"methods": "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_units": "0.2.0",
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
|
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
|
||||||
|
|
@ -29,6 +29,14 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.59",
|
||||||
"date": "2026-05-07",
|
"date": "2026-05-07",
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,8 @@ services:
|
||||||
MEDIAWIKI_CATEGORY_MODELS: "${MEDIAWIKI_CATEGORY_MODELS:-Reifegradmodelle}"
|
MEDIAWIKI_CATEGORY_MODELS: "${MEDIAWIKI_CATEGORY_MODELS:-Reifegradmodelle}"
|
||||||
# Medien: Host-Pfad SHINKAN_MEDIA_HOST (in .env), Ziel im Container MEDIA_ROOT.
|
# Medien: Host-Pfad SHINKAN_MEDIA_HOST (in .env), Ziel im Container MEDIA_ROOT.
|
||||||
MEDIA_ROOT: "${MEDIA_ROOT:-/app/media}"
|
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:
|
volumes:
|
||||||
- ${SHINKAN_MEDIA_HOST:-/shinkan-media}:${MEDIA_ROOT:-/app/media}
|
- ${SHINKAN_MEDIA_HOST:-/shinkan-media}:${MEDIA_ROOT:-/app/media}
|
||||||
ports:
|
ports:
|
||||||
|
|
|
||||||
|
|
@ -3,52 +3,8 @@
|
||||||
*/
|
*/
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { sanitizeTrainerHtml } from '../utils/htmlUtils'
|
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
|
||||||
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
|
import ExerciseMediaEmbed from './ExerciseMediaEmbed'
|
||||||
|
|
||||||
function HtmlBlock({ html, className = '' }) {
|
|
||||||
if (!html || !String(html).trim()) return null
|
|
||||||
const safe = sanitizeTrainerHtml(html)
|
|
||||||
return (
|
|
||||||
<div className={`rich-text-content ${className}`} dangerouslySetInnerHTML={{ __html: safe }} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MediaBlock({ media, exerciseId }) {
|
|
||||||
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 = resolveExerciseMediaFileUrl(exerciseId, media)
|
|
||||||
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 TagRow({ exercise }) {
|
function TagRow({ exercise }) {
|
||||||
const tags = []
|
const tags = []
|
||||||
|
|
@ -112,6 +68,7 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
|
||||||
}
|
}
|
||||||
if (!exercise) return null
|
if (!exercise) return null
|
||||||
|
|
||||||
|
const resolvedId = exercise.id ?? exerciseId
|
||||||
const meta = metaParts(exercise)
|
const meta = metaParts(exercise)
|
||||||
const visibleMedia = (exercise.media || []).filter((m) => {
|
const visibleMedia = (exercise.media || []).filter((m) => {
|
||||||
const lc = String(m.asset_lifecycle_state || 'active').toLowerCase()
|
const lc = String(m.asset_lifecycle_state || 'active').toLowerCase()
|
||||||
|
|
@ -132,13 +89,13 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
|
||||||
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px', letterSpacing: '0.04em' }}>
|
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px', letterSpacing: '0.04em' }}>
|
||||||
Kurzbeschreibung
|
Kurzbeschreibung
|
||||||
</h3>
|
</h3>
|
||||||
<HtmlBlock html={exercise.summary} />
|
<ExerciseRichTextBlock html={exercise.summary} exerciseId={resolvedId} media={exercise.media} />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{exercise.goal && (
|
{exercise.goal && (
|
||||||
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
|
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
|
||||||
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>Ziel</h3>
|
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>Ziel</h3>
|
||||||
<HtmlBlock html={exercise.goal} />
|
<ExerciseRichTextBlock html={exercise.goal} exerciseId={resolvedId} media={exercise.media} />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{(exercise.equipment || []).length > 0 && (
|
{(exercise.equipment || []).length > 0 && (
|
||||||
|
|
@ -156,13 +113,13 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
|
||||||
{exercise.preparation && (
|
{exercise.preparation && (
|
||||||
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
|
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
|
||||||
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>Vorbereitung</h3>
|
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>Vorbereitung</h3>
|
||||||
<HtmlBlock html={exercise.preparation} />
|
<ExerciseRichTextBlock html={exercise.preparation} exerciseId={resolvedId} media={exercise.media} />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{exercise.execution && (
|
{exercise.execution && (
|
||||||
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
|
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
|
||||||
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>Ablauf</h3>
|
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>Ablauf</h3>
|
||||||
<HtmlBlock html={exercise.execution} />
|
<ExerciseRichTextBlock html={exercise.execution} exerciseId={resolvedId} media={exercise.media} />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{visibleMedia.length > 0 && (
|
{visibleMedia.length > 0 && (
|
||||||
|
|
@ -179,7 +136,7 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{m.description && <p style={{ color: 'var(--text2)', fontSize: '0.82rem', marginTop: '4px' }}>{m.description}</p>}
|
{m.description && <p style={{ color: 'var(--text2)', fontSize: '0.82rem', marginTop: '4px' }}>{m.description}</p>}
|
||||||
<MediaBlock media={m} exerciseId={exercise.id ?? exerciseId} />
|
<ExerciseMediaEmbed media={m} exerciseId={resolvedId} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -189,7 +146,7 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
|
||||||
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>
|
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>
|
||||||
Hinweise Trainer (Katalog)
|
Hinweise Trainer (Katalog)
|
||||||
</h3>
|
</h3>
|
||||||
<HtmlBlock html={exercise.trainer_notes} />
|
<ExerciseRichTextBlock html={exercise.trainer_notes} exerciseId={resolvedId} media={exercise.media} />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{exerciseId != null && (
|
{exerciseId != null && (
|
||||||
|
|
|
||||||
43
frontend/src/components/ExerciseMediaEmbed.jsx
Normal file
43
frontend/src/components/ExerciseMediaEmbed.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<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 = resolveExerciseMediaFileUrl(exerciseId, media)
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -4,15 +4,7 @@
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import { sanitizeTrainerHtml } from '../utils/htmlUtils'
|
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
|
||||||
|
|
||||||
function HtmlBlock({ html, className = '' }) {
|
|
||||||
if (!html || !String(html).trim()) return null
|
|
||||||
const safe = sanitizeTrainerHtml(html)
|
|
||||||
return (
|
|
||||||
<div className={`rich-text-content ${className}`} dangerouslySetInnerHTML={{ __html: safe }} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TagMini({ exercise }) {
|
function TagMini({ exercise }) {
|
||||||
const parts = []
|
const parts = []
|
||||||
|
|
@ -129,7 +121,7 @@ export default function ExercisePeekModal({
|
||||||
</div>
|
</div>
|
||||||
{variant.description ? (
|
{variant.description ? (
|
||||||
<div style={{ marginTop: 8, fontSize: '0.9rem', color: 'var(--text2)' }}>
|
<div style={{ marginTop: 8, fontSize: '0.9rem', color: 'var(--text2)' }}>
|
||||||
<HtmlBlock html={variant.description} />
|
<ExerciseRichTextBlock html={variant.description} exerciseId={exercise.id} media={exercise.media} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{variant.execution_changes ? (
|
{variant.execution_changes ? (
|
||||||
|
|
@ -137,14 +129,14 @@ export default function ExercisePeekModal({
|
||||||
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', marginBottom: 6 }}>
|
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', marginBottom: 6 }}>
|
||||||
Durchführung (Variante)
|
Durchführung (Variante)
|
||||||
</h4>
|
</h4>
|
||||||
<HtmlBlock html={variant.execution_changes} />
|
<ExerciseRichTextBlock html={variant.execution_changes} exerciseId={exercise.id} media={exercise.media} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{exercise.summary && (
|
{exercise.summary && (
|
||||||
<div style={{ fontSize: '0.95rem', color: 'var(--text2)' }}>
|
<div style={{ fontSize: '0.95rem', color: 'var(--text2)' }}>
|
||||||
<HtmlBlock html={exercise.summary} />
|
<ExerciseRichTextBlock html={exercise.summary} exerciseId={exercise.id} media={exercise.media} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<TagMini exercise={exercise} />
|
<TagMini exercise={exercise} />
|
||||||
|
|
@ -154,25 +146,25 @@ export default function ExercisePeekModal({
|
||||||
{exercise.goal && (
|
{exercise.goal && (
|
||||||
<>
|
<>
|
||||||
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', marginBottom: 6 }}>Ziel</h4>
|
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', marginBottom: 6 }}>Ziel</h4>
|
||||||
<HtmlBlock html={exercise.goal} />
|
<ExerciseRichTextBlock html={exercise.goal} exerciseId={exercise.id} media={exercise.media} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{exercise.preparation && (
|
{exercise.preparation && (
|
||||||
<>
|
<>
|
||||||
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', margin: '14px 0 6px' }}>Vorbereitung</h4>
|
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', margin: '14px 0 6px' }}>Vorbereitung</h4>
|
||||||
<HtmlBlock html={exercise.preparation} />
|
<ExerciseRichTextBlock html={exercise.preparation} exerciseId={exercise.id} media={exercise.media} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{exercise.execution && (
|
{exercise.execution && (
|
||||||
<>
|
<>
|
||||||
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', margin: '14px 0 6px' }}>Ablauf</h4>
|
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', margin: '14px 0 6px' }}>Ablauf</h4>
|
||||||
<HtmlBlock html={exercise.execution} />
|
<ExerciseRichTextBlock html={exercise.execution} exerciseId={exercise.id} media={exercise.media} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{exercise.trainer_notes && (
|
{exercise.trainer_notes && (
|
||||||
<>
|
<>
|
||||||
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', margin: '14px 0 6px' }}>Trainer-Hinweise</h4>
|
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', margin: '14px 0 6px' }}>Trainer-Hinweise</h4>
|
||||||
<HtmlBlock html={exercise.trainer_notes} />
|
<ExerciseRichTextBlock html={exercise.trainer_notes} exerciseId={exercise.id} media={exercise.media} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
99
frontend/src/components/ExerciseRichTextBlock.jsx
Normal file
99
frontend/src/components/ExerciseRichTextBlock.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<span key={key} className="shinkan-inline-media-missing" style={{ color: 'var(--text3)', fontSize: '0.9em' }}>
|
||||||
|
[Ungültiger Medienverweis]
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const media = mediaById.get(mid)
|
||||||
|
if (!media) {
|
||||||
|
return (
|
||||||
|
<span key={key} className="shinkan-inline-media-missing" style={{ color: 'var(--text3)', fontSize: '0.9em' }}>
|
||||||
|
[Medium nicht verfügbar]
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const lc = String(media.asset_lifecycle_state || 'active').toLowerCase()
|
||||||
|
return (
|
||||||
|
<span key={key} className="shinkan-inline-media-wrap" style={{ display: 'inline-block', verticalAlign: 'top', maxWidth: '100%' }}>
|
||||||
|
{lc === 'trash_soft' && (
|
||||||
|
<span style={{ fontSize: '0.75rem', color: 'var(--danger)', display: 'block', marginBottom: '4px' }}>
|
||||||
|
Dieses Medium ist im Papierkorb.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ExerciseMediaEmbed exerciseId={exerciseId} media={media} />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <div className={`rich-text-content ${className}`.trim()}>{body}</div>
|
||||||
|
}
|
||||||
|
|
@ -45,10 +45,50 @@ function normalText() {
|
||||||
formatBlock('p')
|
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).
|
* 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 ref = useRef(null)
|
||||||
const [focused, setFocused] = useState(false)
|
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 (
|
return (
|
||||||
<div className="rich-text-editor-wrap">
|
<div className="rich-text-editor-wrap">
|
||||||
<div className="rich-text-toolbar" role="toolbar" aria-label="Formatierung">
|
<div className="rich-text-toolbar" role="toolbar" aria-label="Formatierung">
|
||||||
|
|
@ -133,6 +205,16 @@ export default function RichTextEditor({ value, onChange, placeholder, minHeight
|
||||||
<button type="button" className="rte-btn" title="Link einfügen" onMouseDown={onLink}>
|
<button type="button" className="rte-btn" title="Link einfügen" onMouseDown={onLink}>
|
||||||
Link
|
Link
|
||||||
</button>
|
</button>
|
||||||
|
{showMediaPick ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rte-btn"
|
||||||
|
title="Übungs-Medium inline einfügen (§11)"
|
||||||
|
onMouseDown={onInsertExerciseMediaClick}
|
||||||
|
>
|
||||||
|
Bild/Video im Text
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rte-btn"
|
className="rte-btn"
|
||||||
|
|
|
||||||
|
|
@ -1,57 +1,10 @@
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
|
import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
|
||||||
import { sanitizeTrainerHtml } from '../utils/htmlUtils'
|
import ExerciseMediaEmbed from '../components/ExerciseMediaEmbed'
|
||||||
import { formatSkillLevelSlug } from '../constants/skillLevels'
|
import { formatSkillLevelSlug } from '../constants/skillLevels'
|
||||||
|
|
||||||
function HtmlBlock({ html, className = '' }) {
|
|
||||||
if (!html || !String(html).trim()) return null
|
|
||||||
const safe = sanitizeTrainerHtml(html)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`rich-text-content ${className}`}
|
|
||||||
dangerouslySetInnerHTML={{ __html: safe }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MediaBlock({ media, exerciseId }) {
|
|
||||||
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 = resolveExerciseMediaFileUrl(exerciseId, media)
|
|
||||||
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 TagRow({ exercise }) {
|
function TagRow({ exercise }) {
|
||||||
const tags = []
|
const tags = []
|
||||||
;(exercise.focus_areas || []).forEach((f) => {
|
;(exercise.focus_areas || []).forEach((f) => {
|
||||||
|
|
@ -171,7 +124,7 @@ function ExerciseDetailPage() {
|
||||||
<h1 style={{ margin: 0, fontSize: '1.35rem', lineHeight: 1.25 }}>{exercise.title}</h1>
|
<h1 style={{ margin: 0, fontSize: '1.35rem', lineHeight: 1.25 }}>{exercise.title}</h1>
|
||||||
{exercise.summary && (
|
{exercise.summary && (
|
||||||
<div style={{ marginTop: '10px', color: 'var(--text2)', fontSize: '15px' }}>
|
<div style={{ marginTop: '10px', color: 'var(--text2)', fontSize: '15px' }}>
|
||||||
<HtmlBlock html={exercise.summary} />
|
<ExerciseRichTextBlock html={exercise.summary} exerciseId={exercise.id} media={exercise.media} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<TagRow exercise={exercise} />
|
<TagRow exercise={exercise} />
|
||||||
|
|
@ -186,7 +139,7 @@ function ExerciseDetailPage() {
|
||||||
{exercise.goal && (
|
{exercise.goal && (
|
||||||
<section className="card exercise-detail-section">
|
<section className="card exercise-detail-section">
|
||||||
<h2>Ziel</h2>
|
<h2>Ziel</h2>
|
||||||
<HtmlBlock html={exercise.goal} />
|
<ExerciseRichTextBlock html={exercise.goal} exerciseId={exercise.id} media={exercise.media} />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -204,14 +157,14 @@ function ExerciseDetailPage() {
|
||||||
{exercise.preparation && (
|
{exercise.preparation && (
|
||||||
<section className="card exercise-detail-section">
|
<section className="card exercise-detail-section">
|
||||||
<h2>Vorbereitung</h2>
|
<h2>Vorbereitung</h2>
|
||||||
<HtmlBlock html={exercise.preparation} />
|
<ExerciseRichTextBlock html={exercise.preparation} exerciseId={exercise.id} media={exercise.media} />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{exercise.execution && (
|
{exercise.execution && (
|
||||||
<section className="card exercise-detail-section">
|
<section className="card exercise-detail-section">
|
||||||
<h2>Ablauf</h2>
|
<h2>Ablauf</h2>
|
||||||
<HtmlBlock html={exercise.execution} />
|
<ExerciseRichTextBlock html={exercise.execution} exerciseId={exercise.id} media={exercise.media} />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -227,7 +180,7 @@ function ExerciseDetailPage() {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{m.description && <p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{m.description}</p>}
|
{m.description && <p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{m.description}</p>}
|
||||||
<MediaBlock media={m} exerciseId={exercise.id} />
|
<ExerciseMediaEmbed media={m} exerciseId={exercise.id} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -236,7 +189,7 @@ function ExerciseDetailPage() {
|
||||||
{exercise.trainer_notes && (
|
{exercise.trainer_notes && (
|
||||||
<section className="card exercise-detail-section">
|
<section className="card exercise-detail-section">
|
||||||
<h2>Hinweise für Trainer</h2>
|
<h2>Hinweise für Trainer</h2>
|
||||||
<HtmlBlock html={exercise.trainer_notes} />
|
<ExerciseRichTextBlock html={exercise.trainer_notes} exerciseId={exercise.id} media={exercise.media} />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -307,7 +260,11 @@ function ExerciseDetailPage() {
|
||||||
{v.description && <p style={{ color: 'var(--text2)', marginTop: '4px' }}>{v.description}</p>}
|
{v.description && <p style={{ color: 'var(--text2)', marginTop: '4px' }}>{v.description}</p>}
|
||||||
{v.execution_changes && (
|
{v.execution_changes && (
|
||||||
<div style={{ marginTop: '8px' }}>
|
<div style={{ marginTop: '8px' }}>
|
||||||
<HtmlBlock html={v.execution_changes} />
|
<ExerciseRichTextBlock
|
||||||
|
html={v.execution_changes}
|
||||||
|
exerciseId={exercise.id}
|
||||||
|
media={exercise.media}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,7 @@ function buildVariantPayloadFromRow(row) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Gemeinsame Felder für „Variante bearbeiten“ und „Neue Variante“. */
|
/** 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
|
|
@ -198,6 +198,7 @@ function ExerciseVariantFields({ row, onPatch, prerequisiteOthers, rteMinHeight
|
||||||
onChange={(html) => onPatch({ execution_changes: html })}
|
onChange={(html) => onPatch({ execution_changes: html })}
|
||||||
placeholder="Was unterscheidet diese Variante? (Listen über Symbolleiste)"
|
placeholder="Was unterscheidet diese Variante? (Listen über Symbolleiste)"
|
||||||
minHeight={rteMinHeight}
|
minHeight={rteMinHeight}
|
||||||
|
insertExerciseMediaSlots={exerciseMediaInsertSlots}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||||||
|
|
@ -459,6 +460,16 @@ function ExerciseFormPage() {
|
||||||
const [archiveError, setArchiveError] = useState(null)
|
const [archiveError, setArchiveError] = useState(null)
|
||||||
const [mediaPreview, setMediaPreview] = 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(() => {
|
useEffect(() => {
|
||||||
const next = {}
|
const next = {}
|
||||||
for (const m of mediaList) {
|
for (const m of mediaList) {
|
||||||
|
|
@ -1040,6 +1051,7 @@ function ExerciseFormPage() {
|
||||||
onChange={(html) => updateFormField('summary', html)}
|
onChange={(html) => updateFormField('summary', html)}
|
||||||
placeholder="Kurzbeschreibung (optional)"
|
placeholder="Kurzbeschreibung (optional)"
|
||||||
minHeight="80px"
|
minHeight="80px"
|
||||||
|
insertExerciseMediaSlots={exerciseMediaInsertSlots}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1050,6 +1062,7 @@ function ExerciseFormPage() {
|
||||||
onChange={(html) => updateFormField('goal', html)}
|
onChange={(html) => updateFormField('goal', html)}
|
||||||
placeholder="Trainingsziel"
|
placeholder="Trainingsziel"
|
||||||
minHeight="120px"
|
minHeight="120px"
|
||||||
|
insertExerciseMediaSlots={exerciseMediaInsertSlots}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1060,6 +1073,7 @@ function ExerciseFormPage() {
|
||||||
onChange={(html) => updateFormField('execution', html)}
|
onChange={(html) => updateFormField('execution', html)}
|
||||||
placeholder="Ablauf Schritt für Schritt"
|
placeholder="Ablauf Schritt für Schritt"
|
||||||
minHeight="180px"
|
minHeight="180px"
|
||||||
|
insertExerciseMediaSlots={exerciseMediaInsertSlots}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1070,6 +1084,7 @@ function ExerciseFormPage() {
|
||||||
onChange={(html) => updateFormField('preparation', html)}
|
onChange={(html) => updateFormField('preparation', html)}
|
||||||
placeholder="Matten, Raum, …"
|
placeholder="Matten, Raum, …"
|
||||||
minHeight="100px"
|
minHeight="100px"
|
||||||
|
insertExerciseMediaSlots={exerciseMediaInsertSlots}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1080,6 +1095,7 @@ function ExerciseFormPage() {
|
||||||
onChange={(html) => updateFormField('trainer_notes', html)}
|
onChange={(html) => updateFormField('trainer_notes', html)}
|
||||||
placeholder="Sicherheit, Varianten-Hinweise, …"
|
placeholder="Sicherheit, Varianten-Hinweise, …"
|
||||||
minHeight="100px"
|
minHeight="100px"
|
||||||
|
insertExerciseMediaSlots={exerciseMediaInsertSlots}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1374,6 +1390,7 @@ function ExerciseFormPage() {
|
||||||
onPatch={(patch) => setVariantDraft((d) => ({ ...d, ...patch }))}
|
onPatch={(patch) => setVariantDraft((d) => ({ ...d, ...patch }))}
|
||||||
prerequisiteOthers={variants}
|
prerequisiteOthers={variants}
|
||||||
rteMinHeight="110px"
|
rteMinHeight="110px"
|
||||||
|
exerciseMediaInsertSlots={exerciseMediaInsertSlots}
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="btn btn-primary" style={{ marginTop: '10px' }} disabled={variantBusy}>
|
<button type="submit" className="btn btn-primary" style={{ marginTop: '10px' }} disabled={variantBusy}>
|
||||||
{variantBusy ? 'Anlegen…' : 'Variante anlegen'}
|
{variantBusy ? 'Anlegen…' : 'Variante anlegen'}
|
||||||
|
|
@ -1440,6 +1457,7 @@ function ExerciseFormPage() {
|
||||||
onPatch={(patch) => updateVariantField(selectedVariantForEdit.id, patch)}
|
onPatch={(patch) => updateVariantField(selectedVariantForEdit.id, patch)}
|
||||||
prerequisiteOthers={variants.filter((o) => o.id !== selectedVariantForEdit.id)}
|
prerequisiteOthers={variants.filter((o) => o.id !== selectedVariantForEdit.id)}
|
||||||
rteMinHeight="110px"
|
rteMinHeight="110px"
|
||||||
|
exerciseMediaInsertSlots={exerciseMediaInsertSlots}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import MultiSelectCombo from '../components/MultiSelectCombo'
|
||||||
import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker'
|
import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker'
|
||||||
import CatalogRulePicker from '../components/CatalogRulePicker'
|
import CatalogRulePicker from '../components/CatalogRulePicker'
|
||||||
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
|
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
|
||||||
|
import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
|
||||||
import PageSectionNav from '../components/PageSectionNav'
|
import PageSectionNav from '../components/PageSectionNav'
|
||||||
import {
|
import {
|
||||||
INITIAL_EXERCISE_LIST_FILTERS,
|
INITIAL_EXERCISE_LIST_FILTERS,
|
||||||
|
|
@ -27,7 +28,7 @@ import {
|
||||||
splitMnCatalogRules,
|
splitMnCatalogRules,
|
||||||
splitScalarCatalogRules,
|
splitScalarCatalogRules,
|
||||||
} from '../constants/exerciseListFilters'
|
} from '../constants/exerciseListFilters'
|
||||||
import { sanitizeExerciseRichText, coerceApiNameList } from '../utils/sanitizeHtml'
|
import { coerceApiNameList } from '../utils/sanitizeHtml'
|
||||||
import { canUserRequestExerciseDelete } from '../utils/exercisePermissions'
|
import { canUserRequestExerciseDelete } from '../utils/exercisePermissions'
|
||||||
|
|
||||||
const PAGE_SIZE = 100
|
const PAGE_SIZE = 100
|
||||||
|
|
@ -1380,9 +1381,6 @@ function ExercisesListPage() {
|
||||||
const focusNames = exerciseFocusNames(exercise)
|
const focusNames = exerciseFocusNames(exercise)
|
||||||
const styleNames = coerceApiNameList(exercise.style_direction_names)
|
const styleNames = coerceApiNameList(exercise.style_direction_names)
|
||||||
const typeNames = coerceApiNameList(exercise.training_type_names)
|
const typeNames = coerceApiNameList(exercise.training_type_names)
|
||||||
const summaryHtml = exercise.summary
|
|
||||||
? sanitizeExerciseRichText(exercise.summary)
|
|
||||||
: ''
|
|
||||||
return (
|
return (
|
||||||
<div key={exercise.id} className={exerciseCardClassName(exercise, user?.id)}>
|
<div key={exercise.id} className={exerciseCardClassName(exercise, user?.id)}>
|
||||||
<div className="exercise-card-layout exercise-card-layout--grow">
|
<div className="exercise-card-layout exercise-card-layout--grow">
|
||||||
|
|
@ -1410,11 +1408,14 @@ function ExercisesListPage() {
|
||||||
<span key={`tt:${name}`} className="exercise-tag exercise-tag--training">{name}</span>
|
<span key={`tt:${name}`} className="exercise-tag exercise-tag--training">{name}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{summaryHtml ? (
|
{exercise.summary && String(exercise.summary).trim() ? (
|
||||||
<div
|
<div className="exercise-card-summary exercise-card-summary--rich">
|
||||||
className="exercise-card-summary exercise-card-summary--rich"
|
<ExerciseRichTextBlock
|
||||||
dangerouslySetInnerHTML={{ __html: summaryHtml }}
|
html={exercise.summary}
|
||||||
/>
|
exerciseId={exercise.id}
|
||||||
|
media={exercise.media || []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
99
frontend/src/utils/exerciseRichTextSanitize.js
Normal file
99
frontend/src/utils/exerciseRichTextSanitize.js
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
/**
|
||||||
|
* Sanitizer für Übungs-Rich-HTML inkl. §11 Platzhalter (span data-shinkan-exercise-media).
|
||||||
|
* Restriktiver als sanitizeTrainerHtml: Allowlist für XSS-Minimierung.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ALLOWED_BLOCK = new Set(['p', 'div', 'br', 'ul', 'ol', 'li', 'h3'])
|
||||||
|
const ALLOWED_INLINE = new Set(['b', 'strong', 'i', 'em', 'u', 'span', 'a'])
|
||||||
|
|
||||||
|
function isHttpsUrl(val) {
|
||||||
|
if (!val || typeof val !== 'string') return false
|
||||||
|
const s = val.trim()
|
||||||
|
return s.startsWith('http://') || s.startsWith('https://')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur für unsere Embed-Markierung: erlaubt data-attribut und optionale Marker-Klasse. */
|
||||||
|
function isInlineExerciseMediaPlaceholderSpan(el) {
|
||||||
|
if (!el?.getAttribute || el.tagName.toLowerCase() !== 'span') return false
|
||||||
|
const raw = el.getAttribute('data-shinkan-exercise-media')
|
||||||
|
if (!raw || !String(raw).trim().match(/^\d+$/)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeAttributes(el, tagLower) {
|
||||||
|
if (tagLower === 'a') {
|
||||||
|
const href = el.getAttribute('href')
|
||||||
|
if (href && isHttpsUrl(href)) {
|
||||||
|
const out = document.createElement('a')
|
||||||
|
out.setAttribute('href', href.trim())
|
||||||
|
return out.attributes
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (tagLower === 'span' && isInlineExerciseMediaPlaceholderSpan(el)) {
|
||||||
|
const out = document.createElement('span')
|
||||||
|
out.setAttribute('data-shinkan-exercise-media', el.getAttribute('data-shinkan-exercise-media').trim())
|
||||||
|
const cls = (el.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean)
|
||||||
|
const keep = cls.filter((c) => c === 'shinkan-inline-media')
|
||||||
|
if (keep.length) out.setAttribute('class', keep.join(' '))
|
||||||
|
return out.attributes
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneAsAllowed(tagLower, el) {
|
||||||
|
const fresh = document.createElement(tagLower)
|
||||||
|
const attrs = sanitizeAttributes(el, tagLower)
|
||||||
|
for (const a of attrs) {
|
||||||
|
fresh.setAttribute(a.name, a.value)
|
||||||
|
}
|
||||||
|
return fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanTree(parent) {
|
||||||
|
const nodes = Array.from(parent.childNodes)
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) continue
|
||||||
|
if (node.nodeType === Node.COMMENT_NODE) {
|
||||||
|
parent.removeChild(node)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||||
|
parent.removeChild(node)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const tag = node.tagName.toLowerCase()
|
||||||
|
if (tag === 'script' || tag === 'iframe' || tag === 'object' || tag === 'embed') {
|
||||||
|
parent.removeChild(node)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (ALLOWED_BLOCK.has(tag) || ALLOWED_INLINE.has(tag)) {
|
||||||
|
const repl = cloneAsAllowed(tag, node)
|
||||||
|
while (node.firstChild) {
|
||||||
|
repl.appendChild(node.firstChild)
|
||||||
|
}
|
||||||
|
cleanTree(repl)
|
||||||
|
parent.replaceChild(repl, node)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
while (node.firstChild) {
|
||||||
|
parent.insertBefore(node.firstChild, node)
|
||||||
|
}
|
||||||
|
parent.removeChild(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string|null|undefined} html
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function sanitizeExerciseRichDisplayHtml(html) {
|
||||||
|
if (html == null || typeof html !== 'string') return ''
|
||||||
|
const trimmed = html.trim()
|
||||||
|
if (!trimmed) return ''
|
||||||
|
|
||||||
|
const tpl = document.createElement('template')
|
||||||
|
tpl.innerHTML = trimmed
|
||||||
|
cleanTree(tpl.content)
|
||||||
|
return tpl.innerHTML
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user