From cc51b0f08f95226472ac2929c077e0fb83c4a13b Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 8 May 2026 11:44:29 +0200 Subject: [PATCH 1/5] 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 ( -
- - {media.embed_url} - - {media.embed_platform && ( - - ({media.embed_platform}) - - )} -
- ) - } - const src = resolveExerciseMediaFileUrl(exerciseId, media) - if (!src) return null - if (media.media_type === 'image' || (media.mime_type && media.mime_type.startsWith('image/'))) { - return ( - {media.title - ) - } - if (media.media_type === 'video' || (media.mime_type && media.mime_type.startsWith('video/'))) { - return
))} @@ -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 ( +
+ + {media.embed_url} + + {media.embed_platform && ( + + ({media.embed_platform}) + + )} +
+ ) + } + const src = resolveExerciseMediaFileUrl(exerciseId, media) + if (!src) return null + if (media.media_type === 'image' || (media.mime_type && media.mime_type.startsWith('image/'))) { + return ( + {media.title + ) + } + if (media.media_type === 'video' || (media.mime_type && media.mime_type.startsWith('video/'))) { + return