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..0e996a9 --- /dev/null +++ b/backend/exercise_rich_text.py @@ -0,0 +1,101 @@ +""" +Ü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..612bf36 --- /dev/null +++ b/backend/tests/test_exercise_rich_text.py @@ -0,0 +1,44 @@ +"""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 'data-shinkan-exercise-media-size="medium"' 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..1272717 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.64" +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,42 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.64", + "date": "2026-05-08", + "changes": [ + "Übung bearbeiten: Auto-Scroll beim Drag von Medien zu Textfeldern; Medien-Kacheln mehrspaltig; Sektion/Ablauf-Zuordnung an Medien entfernt (nur noch Titel bearbeiten); Picker: Video-Vorschau-Frame; Katalogvorschau ohne Anhangs-Medienliste; Ansehen mit Speichern-Hinweis + Zurück zur Bearbeitung von der Ansicht", + ], + }, + { + "version": "0.8.63", + "date": "2026-05-08", + "changes": [ + "RTE/Übung Medien: Picker-Thumbnails; Dateiauswahl-Anzeige; bereits verknüpfte Archive-Medien ins Fließtext einfügen; Platzhalter-Caption data-shinkan-exercise-media-caption + Caret nach ZWSP; Lesemodus: Medienliste nur für nicht eingebettete Anhänge; bearbeiten: kompakte Kacheln mit Drag-and-Drop in Textfelder, Upload unter Medien entfällt", + ], + }, + { + "version": "0.8.62", + "date": "2026-05-08", + "changes": [ + "RTE Inline-Medien: Modals Mediathek+Hochladen + „Embed im Text“; Darstellungsgröße small|medium|full (data-shinkan-exercise-media-size); Lesemodus begrenzt Bild/Video-Breite", + ], + }, + { + "version": "0.8.61", + "date": "2026-05-08", + "changes": [ + "RTE „Bild/Video im Text“: eingebaute Hilfe (Caret + insertHTML/Fallback); sichtbarer 📎-Chip im Editor; Hinweis bei fehlgeschlagener Einfügung/Prompt-ID", + ], + }, + { + "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/app.css b/frontend/src/app.css index bcd4fad..bccdf36 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -3895,7 +3895,30 @@ a.analysis-split__nav-item { overflow-y: auto; resize: vertical; } -/* Listen im Editor (nicht nur in .rich-text-content) – sonst „unsichtbare“ Bullets */ + +/* §11 Inline-Marker: im Editor sichtbar (DOM ist nur leeres span + ZWJ — sonst „passiert nichts“-Effekt) */ +.rich-text-editor span.shinkan-inline-media { + display: inline-block; + vertical-align: baseline; + margin: 2px 4px; + padding: 2px 8px; + border-radius: 6px; + border: 1px dashed var(--accent); + background: var(--surface2); + font-size: 13px; + font-weight: 600; + color: var(--accent-dark); + line-height: 1.35; + cursor: default; +} +.rich-text-editor span.shinkan-inline-media::before { + content: '📎 #' attr(data-shinkan-exercise-media) ' · ' attr(data-shinkan-exercise-media-size); +} +.rich-text-editor span.shinkan-inline-media[data-shinkan-exercise-media-caption]::before { + content: '📎 ' attr(data-shinkan-exercise-media-caption) ' · #' attr(data-shinkan-exercise-media) ' · ' attr(data-shinkan-exercise-media-size); +} + +/* Listen im Editor */ .rich-text-editor ul, .rich-text-editor ol { margin: 0.35rem 0; @@ -3921,6 +3944,252 @@ a.analysis-split__nav-item { pointer-events: none; } +.rich-text-content .shinkan-inline-media-wrap--sm { + max-width: min(280px, 92vw); +} +.rich-text-content .shinkan-inline-media-wrap--md { + max-width: min(560px, 92vw); +} +.rich-text-content .shinkan-inline-media-wrap--full { + max-width: 100%; +} + +.btn-secondary.rte-tab--active { + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(29, 158, 117, 0.22); +} + +.rte-inline-asset-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); + gap: 8px; +} +.rte-inline-asset-tile { + display: flex; + flex-direction: column; + align-items: stretch; + text-align: left; + gap: 4px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface); + cursor: pointer; + font: inherit; + color: inherit; +} +.rte-inline-asset-tile:hover { + border-color: var(--accent); +} +.rte-inline-asset-tile--selected { + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(29, 158, 117, 0.2); +} +.rte-inline-asset-tile__meta { + font-size: 11px; + color: var(--text3); + text-transform: uppercase; + letter-spacing: 0.03em; +} +.rte-inline-asset-tile__name { + font-size: 13px; + line-height: 1.3; + color: var(--text1); + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.rte-inline-asset-tile__thumb { + width: 100%; + aspect-ratio: 4 / 3; + border-radius: 6px; + overflow: hidden; + background: var(--surface2); + border: 1px solid rgba(127, 127, 127, 0.12); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 2px; +} +.rte-inline-asset-tile__thumb-img { + width: 100%; + height: 100%; + object-fit: cover; +} +.rte-inline-asset-tile__thumb-video { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + pointer-events: none; +} +.rte-inline-asset-tile__thumb-fallback { + font-size: 11px; + color: var(--text3); +} +.rte-inline-asset-tile__badge { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--accent-dark); +} + +.rte-inline-file-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + position: relative; +} +.rte-inline-file-input-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +.rte-inline-file-pick-btn { + flex-shrink: 0; + cursor: pointer; + margin: 0; +} +.rte-inline-file-name { + flex: 1 1 160px; + font-size: 13px; + color: var(--text2); + line-height: 1.35; + min-width: 0; + word-break: break-word; +} + +.exercise-edit-media-strip { + list-style: none; + padding: 0; + margin: 14px 0 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 12px; + align-items: stretch; +} +.exercise-edit-media-strip__item { + display: flex; + gap: 12px; + align-items: stretch; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); +} +.exercise-edit-media-strip__lead { + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + width: 96px; +} +.exercise-edit-media-strip__handle { + width: 100%; + text-align: center; + font-size: 14px; + line-height: 1.2; + padding: 6px 4px; + border-radius: 8px; + border: 1px dashed var(--accent); + background: rgba(29, 158, 117, 0.08); + color: var(--accent-dark); + cursor: grab; + user-select: none; +} +.exercise-edit-media-strip__handle:active { + cursor: grabbing; +} +.exercise-edit-media-strip__handle-text { + font-size: 11px; + font-weight: 600; +} +.exercise-edit-media-strip__embed-badge--solo { + width: 76px; + min-height: 76px; + border-radius: 8px; + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + background: var(--surface2); + font-size: 12px; + font-weight: 600; + color: var(--text2); + padding: 6px; + text-align: center; +} + +.exercise-edit-media-strip__body { + flex: 1; + min-width: 0; +} +.exercise-edit-media-strip__toolbar { + display: grid; + grid-template-columns: 1fr; + gap: 8px; + margin-top: 8px; +} +.exercise-edit-media-strip__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; + align-items: center; +} + +.exercise-orphan-media-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 12px; + margin-top: 12px; +} +.exercise-orphan-media-card { + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 12px; + background: var(--surface); +} +.exercise-orphan-media-card__head { + display: flex; + gap: 10px; + align-items: flex-start; + margin-bottom: 8px; +} +.exercise-orphan-media-card__meta { + flex: 1; + min-width: 0; +} +.exercise-orphan-media-card__title { + font-size: 14px; + display: block; + line-height: 1.3; + word-break: break-word; +} +.exercise-orphan-media-card__sub { + display: block; + font-size: 11px; + color: var(--text3); + margin-top: 4px; +} +.exercise-orphan-media-card__warn { + display: block; + font-size: 11px; + color: var(--danger); + margin-top: 4px; +} + .rich-text-content { font-size: 16px; line-height: 1.55; diff --git a/frontend/src/components/ExerciseAttachmentMediaStrip.jsx b/frontend/src/components/ExerciseAttachmentMediaStrip.jsx new file mode 100644 index 0000000..503868a --- /dev/null +++ b/frontend/src/components/ExerciseAttachmentMediaStrip.jsx @@ -0,0 +1,123 @@ +/** + * Nur Medien, die noch nicht im Fließtext eingebettet sind — ohne Doppel-Darstellung. + */ +import React, { useMemo, useState } from 'react' +import ExerciseMediaEmbed from './ExerciseMediaEmbed' +import ExerciseMediaThumbTile from './ExerciseMediaThumbTile' +import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl' +import { + collectInlineExerciseMediaIdsFromExercise, +} from '../utils/exerciseInlineMediaRefs' + +function isTrashHidden(m) { + return String(m?.asset_lifecycle_state || 'active').toLowerCase() === 'trash_hidden' +} + +export default function ExerciseAttachmentMediaStrip({ exerciseId, exercise }) { + const [preview, setPreview] = useState(null) + const inlineIds = useMemo(() => collectInlineExerciseMediaIdsFromExercise(exercise), [exercise]) + + const orphans = useMemo(() => { + const list = (exercise?.media || []).filter((m) => m && !isTrashHidden(m)) + return list.filter((m) => !inlineIds.has(Number(m.id))) + }, [exercise, inlineIds]) + + if (!orphans.length || exerciseId == null) return null + + return ( +
+

Angehängte Medien

+

+ Hier erscheinen nur Verknüpfungen, die noch nicht im Fließtext eingebettet sind (reine Material-Anhänge). +

+
+ {orphans.map((m) => { + const lc = String(m.asset_lifecycle_state || 'active').toLowerCase() + const caption = (m.title || '').trim() || (m.original_filename || '').trim() || `Medium #${m.id}` + return ( +
+
+ +
+ {caption} + + #{m.id} + {m.embed_platform ? ` · ${m.embed_platform}` : ''} + {m.media_type ? ` · ${m.media_type}` : ''} + + {lc === 'trash_soft' && ( + Papierkorb (Stufe 1) + )} +
+
+ +
+ ) + })} +
+ {preview && ( +
setPreview(null)} + onKeyDown={(e) => e.key === 'Escape' && setPreview(null)} + > +
e.stopPropagation()} + > +

Vorschau

+ {preview.embed_url ? ( +

+ + {preview.embed_url} + +

+ ) : preview.mime_type?.startsWith('video/') || preview.media_type === 'video' ? ( +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/ExerciseFullContent.jsx b/frontend/src/components/ExerciseFullContent.jsx index cd94b9a..73253b8 100644 --- a/frontend/src/components/ExerciseFullContent.jsx +++ b/frontend/src/components/ExerciseFullContent.jsx @@ -3,52 +3,7 @@ */ 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
) : 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..7ba4078 --- /dev/null +++ b/frontend/src/components/ExerciseRichTextBlock.jsx @@ -0,0 +1,107 @@ +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 rawSize = (el.getAttribute('data-shinkan-exercise-media-size') || 'medium').toLowerCase().trim() + const layoutSize = rawSize === 'small' || rawSize === 'full' ? rawSize : 'medium' + const wrapClass = + layoutSize === 'small' + ? 'shinkan-inline-media-wrap shinkan-inline-media-wrap--sm' + : layoutSize === 'full' + ? 'shinkan-inline-media-wrap shinkan-inline-media-wrap--full' + : 'shinkan-inline-media-wrap shinkan-inline-media-wrap--md' + 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..36604b9 100644 --- a/frontend/src/components/RichTextEditor.jsx +++ b/frontend/src/components/RichTextEditor.jsx @@ -1,4 +1,12 @@ import React, { useRef, useEffect, useState, useCallback } from 'react' +import ExerciseInlineFileMediaModal from './ExerciseInlineFileMediaModal' +import ExerciseInlineEmbedModal from './ExerciseInlineEmbedModal' +import { sanitizeInlineMediaCaption } from '../utils/inlineMediaCaption' +import { + SHINKAN_EXERCISE_MEDIA_DRAG_MIME, + parseExerciseMediaDragPayload, +} from '../utils/exerciseInlineMediaRefs' +import { autoScrollForDragNearEdges } from '../utils/dragAutoScroll' function exec(cmd, value = null) { try { @@ -45,12 +53,140 @@ function normalText() { formatBlock('p') } +function escapeHtmlAttr(s) { + return String(s) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/\u2060
` +} + +function patchExitGlyphAfterPlaceholder(editorEl, mediaId, sel) { + const sid = String(parseInt(String(mediaId), 10)) + const hits = editorEl.querySelectorAll(`span.shinkan-inline-media[data-shinkan-exercise-media="${sid}"]`) + const span = hits[hits.length - 1] + if (!span?.parentNode || !sel) return + let n = span.nextSibling + if (!n || n.nodeType !== Node.TEXT_NODE || !n.textContent.includes('\u200B')) { + const zn = document.createTextNode('\u200B') + span.parentNode.insertBefore(zn, span.nextSibling) + n = zn + } + try { + const r = document.createRange() + r.setStart(n, n.textContent.length) + r.collapse(true) + sel.removeAllRanges() + sel.addRange(r) + } catch { + /* noop */ + } +} + +function insertExerciseMediaPlaceholder(editorEl, mediaId, displaySize = 'medium', caption = '') { + if (!editorEl || mediaId == null) return false + const html = buildInlineExerciseMediaHtml(mediaId, displaySize, caption) + if (!html) return false + + editorEl.focus() + const sel = window.getSelection() + if (!sel) return false + + let caretInside = false + if (sel.rangeCount > 0) { + try { + const r0 = sel.getRangeAt(0) + caretInside = editorEl.contains(r0.commonAncestorContainer) + } catch { + caretInside = false + } + } + if (!caretInside) { + const anchor = document.createRange() + try { + anchor.selectNodeContents(editorEl) + anchor.collapse(false) + } catch { + return false + } + sel.removeAllRanges() + sel.addRange(anchor) + } + + let inserted = false + try { + inserted = document.execCommand('insertHTML', false, html) + } catch { + inserted = false + } + if (inserted) { + patchExitGlyphAfterPlaceholder(editorEl, mediaId, sel) + return true + } + + let range = null + try { + range = sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null + } catch { + range = null + } + if (!range || !editorEl.contains(range.commonAncestorContainer)) { + range = document.createRange() + range.selectNodeContents(editorEl) + range.collapse(false) + sel.removeAllRanges() + sel.addRange(range) + } + try { + const tpl = document.createElement('template') + tpl.innerHTML = html + const span = tpl.content.firstChild + if (!span) return false + range.deleteContents() + range.insertNode(span) + const zn = document.createTextNode('\u200B') + span.parentNode.insertBefore(zn, span.nextSibling) + range.setStart(zn, zn.textContent.length) + range.collapse(true) + sel.removeAllRanges() + sel.addRange(range) + return true + } catch { + return false + } +} + /** - * Leichter WYSIWYG (contenteditable). Wert kommt von außen zuverlässig ins DOM (Edit-Modus). + * Leichter WYSIWYG (contenteditable). + * @param {{ + * linkedExerciseMedia?: object[], + * }} [extra] */ -export default function RichTextEditor({ value, onChange, placeholder, minHeight = '140px' }) { +export default function RichTextEditor({ + value, + onChange, + placeholder, + minHeight = '140px', + inlineExerciseId = null, + linkedExerciseMedia = [], + onExerciseMediaListChanged, +}) { const ref = useRef(null) + const pendingRangeRef = useRef(null) const [focused, setFocused] = useState(false) + const [fileModalOpen, setFileModalOpen] = useState(false) + const [embedModalOpen, setEmbedModalOpen] = useState(false) + + const showInlineToolbar = inlineExerciseId != null && Number(inlineExerciseId) > 0 useEffect(() => { const el = ref.current @@ -66,6 +202,129 @@ export default function RichTextEditor({ value, onChange, placeholder, minHeight onChange(ref.current.innerHTML) }, [onChange]) + const refreshExerciseMedia = useCallback(async () => { + if (onExerciseMediaListChanged) { + await onExerciseMediaListChanged() + } + }, [onExerciseMediaListChanged]) + + const stashRangeAndOpen = useCallback((openFn) => (e) => { + e.preventDefault() + e.stopPropagation() + const el = ref.current + pendingRangeRef.current = el ? saveSelectionInside(el) : null + openFn() + }, []) + + const finalizeInsertFromModal = useCallback( + (mediaId, displaySize, caption) => { + queueMicrotask(() => { + const shell = ref.current + if (!shell) return + shell.focus() + restoreSelection(pendingRangeRef.current) + const ok = insertExerciseMediaPlaceholder(shell, mediaId, displaySize, caption) + if (!ok) { + alert( + 'Einfügen ist fehlgeschlagen — bitte Cursor ins Textfeld setzen und den Schalter erneut verwenden.', + ) + return + } + sync() + shell.focus() + }) + }, + [sync], + ) + + const onEditorKeyDown = useCallback( + (e) => { + const el = ref.current + if (!el || e.key !== 'Enter') return + const sel = window.getSelection() + if (!sel || sel.rangeCount === 0 || !el.contains(sel.focusNode)) return + let node = sel.focusNode + let elNode = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement + const host = elNode?.closest?.('.shinkan-inline-media') + if (!host || !el.contains(host)) return + e.preventDefault() + patchExitGlyphAfterPlaceholder(el, host.getAttribute('data-shinkan-exercise-media'), sel) + try { + exec('insertParagraph') + } catch { + exec('insertLineBreak') + } + sync() + }, + [sync], + ) + + const onEditorDragOver = useCallback( + (e) => { + if (!showInlineToolbar) return + const types = e.dataTransfer?.types ? Array.from(e.dataTransfer.types) : [] + if (types.includes(SHINKAN_EXERCISE_MEDIA_DRAG_MIME)) { + e.preventDefault() + e.dataTransfer.dropEffect = 'copy' + autoScrollForDragNearEdges(e) + } + }, + [showInlineToolbar], + ) + + const onEditorDrop = useCallback( + (e) => { + const el = ref.current + if (!el || !showInlineToolbar) return + const raw = e.dataTransfer?.getData(SHINKAN_EXERCISE_MEDIA_DRAG_MIME) + const parsed = parseExerciseMediaDragPayload(raw) + if (!parsed) return + e.preventDefault() + el.focus() + const sel = window.getSelection() + if (!sel) return + let r = null + try { + if (document.caretRangeFromPoint) { + r = document.caretRangeFromPoint(e.clientX, e.clientY) + } + } catch { + r = null + } + if (!r) { + try { + const p = document.caretPositionFromPoint?.(e.clientX, e.clientY) + if (p?.offsetNode) { + const nr = document.createRange() + nr.setStart(p.offsetNode, p.offset) + nr.collapse(true) + r = nr + } + } catch { + r = null + } + } + if (r && el.contains(r.commonAncestorContainer)) { + sel.removeAllRanges() + sel.addRange(r) + } else { + const anchor = document.createRange() + try { + anchor.selectNodeContents(el) + anchor.collapse(false) + } catch { + return + } + sel.removeAllRanges() + sel.addRange(anchor) + } + insertExerciseMediaPlaceholder(el, parsed.exerciseMediaId, 'medium', parsed.caption) + sync() + el.focus() + }, + [sync, showInlineToolbar], + ) + const run = (fn) => (e) => { e.preventDefault() e.stopPropagation() @@ -133,6 +392,26 @@ export default function RichTextEditor({ value, onChange, placeholder, minHeight + {showInlineToolbar ? ( + <> + + + + ) : null} - - Bearbeiten - +
+ + {fromExerciseEdit ? 'Zurück zur Bearbeitung' : 'Bearbeiten'} + +

{exercise.title}

{exercise.summary && (
- +
)} @@ -186,7 +139,7 @@ function ExerciseDetailPage() { {exercise.goal && (

Ziel

- +
)} @@ -204,39 +157,23 @@ function ExerciseDetailPage() { {exercise.preparation && (

Vorbereitung

- +
)} {exercise.execution && (

Ablauf

- +
)} - {visibleMedia.length > 0 && ( -
-

Medien

- {visibleMedia.map((m) => ( -
- {m.title || m.original_filename || m.media_type} - {String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_soft' && ( -

- Hinweis: Dieses Medium ist im Papierkorb und steht künftig nicht mehr zur Verfügung. -

- )} - {m.description &&

{m.description}

} - -
- ))} -
- )} + {exercise.trainer_notes && (

Hinweise für Trainer

- +
)} @@ -307,7 +244,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..a015b85 100644 --- a/frontend/src/pages/ExerciseFormPage.jsx +++ b/frontend/src/pages/ExerciseFormPage.jsx @@ -4,87 +4,15 @@ import api, { buildExerciseApiPayload } from '../utils/api' import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl' import RichTextEditor from '../components/RichTextEditor' import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel' +import ExerciseMediaThumbTile from '../components/ExerciseMediaThumbTile' +import { + SHINKAN_EXERCISE_MEDIA_DRAG_MIME, + buildExerciseMediaDragPayload, +} from '../utils/exerciseInlineMediaRefs' +import { autoScrollForDragNearEdges } from '../utils/dragAutoScroll' import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels' import { useAuth } from '../context/AuthContext' -/** MIME/Dateiname → Übungs-media_type; null → Dropdown-Fallback. */ -function inferExerciseMediaType(file) { - if (!file) return null - const mime = (file.type || '').toLowerCase() - if (mime.startsWith('image/')) return 'image' - if (mime.startsWith('video/')) return 'video' - if (mime === 'application/pdf' || mime.includes('pdf')) return 'document' - const name = (file.name || '').toLowerCase() - if (/\.(mp4|webm|mov|mkv|avi|m4v|mpeg|mpg)$/.test(name)) return 'video' - if (/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/.test(name)) return 'image' - if (/\.pdf$/.test(name)) return 'document' - return null -} - -/** Kachelvorschau: Video nutzt ersten Frame (metadata), Bild = img. */ -function ExerciseMediaThumbTile({ exerciseId, media, onOpenPreview }) { - const src = !media.embed_url ? resolveExerciseMediaFileUrl(exerciseId, media) : null - const commonStyle = { - width: '100%', - height: '100%', - objectFit: 'cover', - } - return ( -
onOpenPreview(media)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onOpenPreview(media) - } - }} - style={{ - width: 72, - height: 72, - flexShrink: 0, - borderRadius: '8px', - overflow: 'hidden', - background: 'var(--surface2, rgba(127,127,127,0.12))', - border: '1px solid var(--border)', - cursor: 'pointer', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - }} - > - {media.embed_url ? ( - - {media.embed_platform || 'Embed'} - - ) : (media.mime_type?.startsWith('image/') || media.media_type === 'image') && src ? ( - - ) : (media.mime_type?.startsWith('video/') || media.media_type === 'video') && src ? ( -
- ) -} - const INTENSITY_OPTIONS = [ { value: '', label: '—' }, { value: 'niedrig', label: 'niedrig' }, @@ -169,7 +97,15 @@ 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', + inlineExerciseId, + linkedExerciseMedia = [], + onExerciseMediaListChanged, +}) { return ( <>
@@ -198,6 +134,9 @@ function ExerciseVariantFields({ row, onPatch, prerequisiteOthers, rteMinHeight onChange={(html) => onPatch({ execution_changes: html })} placeholder="Was unterscheidet diese Variante? (Listen über Symbolleiste)" minHeight={rteMinHeight} + inlineExerciseId={inlineExerciseId} + linkedExerciseMedia={linkedExerciseMedia} + onExerciseMediaListChanged={onExerciseMediaListChanged} />
@@ -435,6 +374,7 @@ function ExerciseFormPage() { const [mediaList, setMediaList] = useState([]) const [loading, setLoading] = useState(!!isEdit) const [saving, setSaving] = useState(false) + const [formDirty, setFormDirty] = useState(false) const [skillPick, setSkillPick] = useState('') const [variants, setVariants] = useState([]) const [variantDraft, setVariantDraft] = useState(() => emptyVariantDraft()) @@ -443,17 +383,10 @@ function ExerciseFormPage() { const [variantEditSelection, setVariantEditSelection] = useState(null) const variantsDetailsRef = useRef(null) - const [mediaFiles, setMediaFiles] = useState([]) - const [mediaType, setMediaType] = useState('image') - const [mediaTitle, setMediaTitle] = useState('') - const [mediaContext, setMediaContext] = useState('ablauf') - const [embedUrl, setEmbedUrl] = useState('') - const [embedTitle, setEmbedTitle] = useState('') const [mediaFields, setMediaFields] = useState({}) const [mediaSavingId, setMediaSavingId] = useState(null) const [archiveOpen, setArchiveOpen] = useState(false) const [archiveQ, setArchiveQ] = useState('') - const [archiveCtx, setArchiveCtx] = useState('ablauf') const [archiveLoading, setArchiveLoading] = useState(false) const [archiveItems, setArchiveItems] = useState([]) const [archiveError, setArchiveError] = useState(null) @@ -464,12 +397,32 @@ function ExerciseFormPage() { for (const m of mediaList) { next[m.id] = { title: m.title || '', - context: m.context || 'ablauf', } } setMediaFields(next) }, [mediaList]) + useEffect(() => { + const onDragOverDoc = (e) => { + const types = e.dataTransfer?.types ? Array.from(e.dataTransfer.types) : [] + if (!types.includes(SHINKAN_EXERCISE_MEDIA_DRAG_MIME)) return + e.preventDefault() + autoScrollForDragNearEdges(e) + } + document.addEventListener('dragover', onDragOverDoc) + return () => document.removeEventListener('dragover', onDragOverDoc) + }, []) + + useEffect(() => { + if (!formDirty) return undefined + const warn = (ev) => { + ev.preventDefault() + ev.returnValue = '' + } + window.addEventListener('beforeunload', warn) + return () => window.removeEventListener('beforeunload', warn) + }, [formDirty]) + useEffect(() => { if (!archiveOpen) return undefined let cancelled = false @@ -534,6 +487,7 @@ function ExerciseFormPage() { setVariants([]) setVariantDraft(emptyVariantDraft()) setVariantEditSelection(null) + setFormDirty(false) setLoading(false) return } @@ -548,6 +502,7 @@ function ExerciseFormPage() { setVariants((exercise.variants || []).map(apiVariantToRow)) setVariantDraft(emptyVariantDraft()) setVariantEditSelection(null) + setFormDirty(false) } catch (err) { if (!cancelled) { alert(err.message || 'Übung nicht ladbar') @@ -577,6 +532,7 @@ function ExerciseFormPage() { }, [variantEditSelection]) const updateFormField = (field, value) => { + setFormDirty(true) setFormData((prev) => ({ ...prev, [field]: value })) } @@ -714,6 +670,7 @@ function ExerciseFormPage() { const ex = await api.getExercise(exerciseId) setMediaList(ex.media || []) setVariants((ex.variants || []).map(apiVariantToRow)) + setFormDirty(false) alert('Gespeichert.') } else { const created = await api.createExercise(payload) @@ -737,7 +694,7 @@ function ExerciseFormPage() { try { await api.attachExerciseMediaFromAsset(exerciseId, { media_asset_id: assetId, - context: archiveCtx, + context: 'ablauf', title: '', description: '', is_primary: false, @@ -754,92 +711,11 @@ function ExerciseFormPage() { [mediaList], ) - const handleUploadFile = async () => { - if (!exerciseId || mediaFiles.length === 0) { - alert('Datei(en) wählen') - return - } - const files = [...mediaFiles] - for (const file of files) { - const inferred = inferExerciseMediaType(file) || mediaType - const fd = new FormData() - fd.append('file', file) - fd.append('media_type', inferred) - fd.append('title', mediaTitle) - fd.append('description', '') - fd.append('context', mediaContext) - fd.append('is_primary', 'false') - try { - await api.uploadExerciseMedia(exerciseId, fd) - } catch (err) { - if (err.code === 'MEDIA_ASSET_IN_TRASH' && err.payload?.media_asset_id != null) { - const aid = err.payload.media_asset_id - const nameHint = file?.name || err.payload.original_filename || 'diese Datei' - if ( - confirm( - `Die hochgeladene Datei ist inhaltsgleich mit einem Archiv-Medium im Papierkorb (${nameHint}). ` + - 'Soll dieses Medium wieder aktiviert und an die Übung gehängt werden? (Es wird kein zweites Exemplar auf der Platte angelegt.)', - ) - ) { - try { - await api.postMediaAssetLifecycle(aid, 'reactivate') - await api.attachExerciseMediaFromAsset(exerciseId, { - media_asset_id: aid, - title: mediaTitle || undefined, - description: '', - context: mediaContext, - is_primary: false, - }) - } catch (e2) { - alert(e2.message || String(e2)) - return - } - } else { - return - } - } else if (err.code === 'MEDIA_ASSET_UNAVAILABLE') { - alert( - (err.message || 'Archiv-Konflikt') + - ' Bitte wenden Sie sich an einen Administrator oder wählen Sie eine andere Datei.', - ) - return - } else { - alert(`Upload (${file.name}): ${err.message || String(err)}`) - return - } - } - } - setMediaFiles([]) - setMediaTitle('') - await refreshMedia() - } - - const handleAddEmbed = async () => { - if (!exerciseId || !embedUrl.trim()) { - alert('Embed-URL eingeben') - return - } - const fd = new FormData() - fd.append('embed_url', embedUrl.trim()) - fd.append('media_type', 'video') - fd.append('title', embedTitle) - fd.append('description', '') - fd.append('context', mediaContext) - fd.append('is_primary', 'false') - try { - await api.uploadExerciseMedia(exerciseId, fd) - setEmbedUrl('') - setEmbedTitle('') - await refreshMedia() - } catch (err) { - alert('Embed: ' + err.message) - } - } - const handleDeleteMedia = async (mid) => { if ( !confirm( - 'Dieses Medium aus der Übung entfernen? Nur die Verknüpfung wird gelöscht. Die Datei bleibt im Archiv, solange sie noch woanders genutzt wird.', + 'Dieses Medium aus der Übung entfernen? Nur die Verknüpfung wird gelöscht. Die Datei bleibt im Archiv, solange sie noch woanders genutzt wird.\n\n' + + 'Hinweis: Wenn dieser Eintrag noch als Platzhalter im Fließtext steht, zeigt die Vorschau [Medium nicht verfügbar] oder das Speichern der Übung schlägt fehl, bis der Platzhalter entfernt ist.', ) ) { return @@ -890,7 +766,6 @@ function ExerciseFormPage() { try { await api.updateExerciseMedia(exerciseId, mid, { title: fld.title.trim() || null, - context: fld.context, }) await refreshMedia() } catch (e) { @@ -907,6 +782,7 @@ function ExerciseFormPage() { } const updateVariantField = (id, patch) => { + setFormDirty(true) setVariants((prev) => prev.map((v) => (v.id === id ? { ...v, ...patch } : v))) } @@ -1010,7 +886,18 @@ function ExerciseFormPage() { type="button" className="btn btn-secondary" style={{ marginLeft: '8px' }} - onClick={() => navigate(`/exercises/${exerciseId}`)} + onClick={() => { + if ( + formDirty && + !window.confirm( + 'Es gibt noch nicht über „Speichern“ gesicherte Änderungen (Texte, Zuordnungen, …).\n\n' + + 'Zur Ansicht wechseln und diese Änderungen verwerfen?', + ) + ) { + return + } + navigate(`/exercises/${exerciseId}`, { state: { fromExerciseEdit: true } }) + }} > Ansehen @@ -1040,6 +927,9 @@ function ExerciseFormPage() { onChange={(html) => updateFormField('summary', html)} placeholder="Kurzbeschreibung (optional)" minHeight="80px" + inlineExerciseId={isEdit ? exerciseId : null} + linkedExerciseMedia={isEdit ? mediaList : []} + onExerciseMediaListChanged={refreshMedia} />
@@ -1050,6 +940,9 @@ function ExerciseFormPage() { onChange={(html) => updateFormField('goal', html)} placeholder="Trainingsziel" minHeight="120px" + inlineExerciseId={isEdit ? exerciseId : null} + linkedExerciseMedia={isEdit ? mediaList : []} + onExerciseMediaListChanged={refreshMedia} /> @@ -1060,6 +953,9 @@ function ExerciseFormPage() { onChange={(html) => updateFormField('execution', html)} placeholder="Ablauf Schritt für Schritt" minHeight="180px" + inlineExerciseId={isEdit ? exerciseId : null} + linkedExerciseMedia={isEdit ? mediaList : []} + onExerciseMediaListChanged={refreshMedia} /> @@ -1070,6 +966,9 @@ function ExerciseFormPage() { onChange={(html) => updateFormField('preparation', html)} placeholder="Matten, Raum, …" minHeight="100px" + inlineExerciseId={isEdit ? exerciseId : null} + linkedExerciseMedia={isEdit ? mediaList : []} + onExerciseMediaListChanged={refreshMedia} /> @@ -1080,6 +979,9 @@ function ExerciseFormPage() { onChange={(html) => updateFormField('trainer_notes', html)} placeholder="Sicherheit, Varianten-Hinweise, …" minHeight="100px" + inlineExerciseId={isEdit ? exerciseId : null} + linkedExerciseMedia={isEdit ? mediaList : []} + onExerciseMediaListChanged={refreshMedia} /> @@ -1371,9 +1273,15 @@ function ExerciseFormPage() {

Neue Variante

setVariantDraft((d) => ({ ...d, ...patch }))} + onPatch={(patch) => { + setFormDirty(true) + setVariantDraft((d) => ({ ...d, ...patch })) + }} prerequisiteOthers={variants} rteMinHeight="110px" + inlineExerciseId={isEdit ? exerciseId : null} + linkedExerciseMedia={isEdit ? mediaList : []} + onExerciseMediaListChanged={refreshMedia} /> - -
- - setEmbedUrl(e.target.value)} - /> - setEmbedTitle(e.target.value)} - style={{ marginTop: '8px' }} - /> - -
- {mediaList.length > 0 && ( - )} +

+ Verknüpfungen bleiben nötig (u. a. Zugriff, Orphan-Hinweise): Im Fließtext verweist du gezielt über + Platzhalter. Ohne Verknüpfung gäbe es keine exercise_media-ID zum Einbetten. +

{archiveOpen && (
setArchiveQ(e.target.value)} style={{ marginBottom: '8px' }} /> - {archiveLoading &&

Laden…

} {archiveError &&

{archiveError}

} {!archiveLoading && !archiveError && archiveItems.length === 0 && ( diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx index eafad09..62b85c5 100644 --- a/frontend/src/pages/ExercisesListPage.jsx +++ b/frontend/src/pages/ExercisesListPage.jsx @@ -19,6 +19,7 @@ import MultiSelectCombo from '../components/MultiSelectCombo' import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker' import CatalogRulePicker from '../components/CatalogRulePicker' import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel' +import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock' import PageSectionNav from '../components/PageSectionNav' import { INITIAL_EXERCISE_LIST_FILTERS, @@ -27,7 +28,7 @@ import { splitMnCatalogRules, splitScalarCatalogRules, } from '../constants/exerciseListFilters' -import { sanitizeExerciseRichText, coerceApiNameList } from '../utils/sanitizeHtml' +import { coerceApiNameList } from '../utils/sanitizeHtml' import { canUserRequestExerciseDelete } from '../utils/exercisePermissions' const PAGE_SIZE = 100 @@ -1380,9 +1381,6 @@ function ExercisesListPage() { const focusNames = exerciseFocusNames(exercise) const styleNames = coerceApiNameList(exercise.style_direction_names) const typeNames = coerceApiNameList(exercise.training_type_names) - const summaryHtml = exercise.summary - ? sanitizeExerciseRichText(exercise.summary) - : '' return (
@@ -1410,11 +1408,14 @@ function ExercisesListPage() { {name} ))}
- {summaryHtml ? ( -
+ {exercise.summary && String(exercise.summary).trim() ? ( +
+ +
) : null}
diff --git a/frontend/src/utils/dragAutoScroll.js b/frontend/src/utils/dragAutoScroll.js new file mode 100644 index 0000000..25f293e --- /dev/null +++ b/frontend/src/utils/dragAutoScroll.js @@ -0,0 +1,66 @@ +/** + * Während eines Drags automatischen Bildlauf auslösen (Viewport + scrollbare Bereiche unter dem Cursor). + * @param {DragEvent} e + * @param {{ edgePx?: number, scrollStep?: number }} [opts] + */ +export function autoScrollForDragNearEdges(e, opts = {}) { + const edge = opts.edgePx ?? 80 + const step = opts.scrollStep ?? Math.max(24, Math.round(window.innerHeight * 0.05)) + const { clientX, clientY } = e + + const vh = window.innerHeight || 0 + const vw = window.innerWidth || 0 + + let sy = + clientY < edge ? -step : vh > 0 && clientY > vh - edge ? step : 0 + let sx = + clientX < edge ? -step : vw > 0 && clientX > vw - edge ? step : 0 + + if (sy !== 0) window.scrollBy(0, sy) + if (sx !== 0) window.scrollBy(sx, 0) + + /** @type {HTMLElement|null} */ + let top = /** @type {HTMLElement|null} */ (document.elementFromPoint(clientX, clientY)) + + /** @type {Set} */ + const done = new Set() + + /** @type {HTMLElement|null} */ + let walk = top + const innerEdge = Math.min(edge, 40) + + while (walk && walk !== document.body) { + if (!(walk instanceof HTMLElement)) break + + const cs = window.getComputedStyle(walk) + const canY = + walk.scrollHeight - walk.clientHeight > 6 && + (cs.overflowY === 'auto' || cs.overflowY === 'scroll') + const canX = + walk.scrollWidth - walk.clientWidth > 6 && + (cs.overflowX === 'auto' || cs.overflowX === 'scroll') + + if ((canY || canX) && !done.has(walk)) { + done.add(walk) + const rect = walk.getBoundingClientRect() + + const relY = clientY - rect.top + const relX = clientX - rect.left + + if (canY) { + if (relY < innerEdge && walk.scrollTop > 0) walk.scrollTop -= step + else if (rect.height - relY < innerEdge && walk.scrollTop < walk.scrollHeight - walk.clientHeight) { + walk.scrollTop += step + } + } + if (canX) { + if (relX < innerEdge && walk.scrollLeft > 0) walk.scrollLeft -= step + else if (rect.width - relX < innerEdge && walk.scrollLeft < walk.scrollWidth - walk.clientWidth) { + walk.scrollLeft += step + } + } + } + + walk = walk.parentElement + } +} diff --git a/frontend/src/utils/exerciseInlineMediaRefs.js b/frontend/src/utils/exerciseInlineMediaRefs.js new file mode 100644 index 0000000..d527044 --- /dev/null +++ b/frontend/src/utils/exerciseInlineMediaRefs.js @@ -0,0 +1,85 @@ +/** + * §11 Inline-Medien: aus HTML / Übungsobjekt referenzierte exercise_media-IDs sammeln. + */ + +const DATA_ATTR_RE = /data-shinkan-exercise-media\s*=\s*["']?(\d+)/gi + +export const SHINKAN_EXERCISE_MEDIA_DRAG_MIME = 'application/x-shinkan-exercise-media' + +/** + * @param {string|null|undefined} html + * @returns {Set} + */ +export function collectInlineExerciseMediaIdsFromHtml(html) { + const ids = new Set() + if (!html || typeof html !== 'string') return ids + let m + const re = new RegExp(DATA_ATTR_RE.source, 'gi') + while ((m = re.exec(html)) !== null) { + const n = parseInt(m[1], 10) + if (Number.isFinite(n) && n > 0) ids.add(n) + } + return ids +} + +const EXERCISE_RTF_FIELDS = ['summary', 'goal', 'execution', 'preparation', 'trainer_notes'] + +/** + * HTML-Schnipsel aus Übung + Varianten-Fließtext für Inline-Scan. + * @param {object|null|undefined} exercise + * @returns {string[]} + */ +export function gatherExerciseHtmlSlicesForInlineScan(exercise) { + if (!exercise || typeof exercise !== 'object') return [] + const slices = [] + for (const f of EXERCISE_RTF_FIELDS) { + const html = exercise[f] + if (typeof html === 'string' && html.trim()) slices.push(html) + } + for (const v of exercise.variants || []) { + const ec = v?.execution_changes + if (typeof ec === 'string' && ec.trim()) slices.push(ec) + } + return slices +} + +/** + * Alle im Fließtext eingebetteten exercise_media-IDs (Übung + Varianten). + * @param {object|null|undefined} exercise + * @returns {Set} + */ +export function collectInlineExerciseMediaIdsFromExercise(exercise) { + const ids = new Set() + for (const html of gatherExerciseHtmlSlicesForInlineScan(exercise)) { + collectInlineExerciseMediaIdsFromHtml(html).forEach((id) => ids.add(id)) + } + return ids +} + +/** + * @param {number} exerciseMediaId + * @param {string} [caption] + */ +export function buildExerciseMediaDragPayload(exerciseMediaId, caption = '') { + return JSON.stringify({ + exerciseMediaId: Number(exerciseMediaId), + caption: typeof caption === 'string' ? caption : '', + }) +} + +/** + * @param {string} raw + * @returns {{ exerciseMediaId: number, caption: string }|null} + */ +export function parseExerciseMediaDragPayload(raw) { + if (!raw || typeof raw !== 'string') return null + try { + const o = JSON.parse(raw) + const id = Number(o.exerciseMediaId) + if (!Number.isFinite(id) || id < 1) return null + const caption = typeof o.caption === 'string' ? o.caption : '' + return { exerciseMediaId: id, caption } + } catch { + return null + } +} diff --git a/frontend/src/utils/exerciseRichTextSanitize.js b/frontend/src/utils/exerciseRichTextSanitize.js new file mode 100644 index 0000000..6a99dd2 --- /dev/null +++ b/frontend/src/utils/exerciseRichTextSanitize.js @@ -0,0 +1,111 @@ +/** + * Sanitizer für Übungs-Rich-HTML inkl. §11 Platzhalter (span data-shinkan-exercise-media). + * Restriktiver als sanitizeTrainerHtml: Allowlist für XSS-Minimierung. + */ + +import { sanitizeInlineMediaCaption } from './inlineMediaCaption' + +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 + Größe. */ +const _SIZE_OK = new Set(['small', 'medium', 'full']) +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 sz = (el.getAttribute('data-shinkan-exercise-media-size') || '').trim().toLowerCase() + if (sz && _SIZE_OK.has(sz)) { + out.setAttribute('data-shinkan-exercise-media-size', sz) + } + const capRaw = el.getAttribute('data-shinkan-exercise-media-caption') + if (capRaw != null && String(capRaw).trim()) { + const cap = sanitizeInlineMediaCaption(String(capRaw)) + if (cap) out.setAttribute('data-shinkan-exercise-media-caption', cap) + } + 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 +} diff --git a/frontend/src/utils/inlineMediaCaption.js b/frontend/src/utils/inlineMediaCaption.js new file mode 100644 index 0000000..be63126 --- /dev/null +++ b/frontend/src/utils/inlineMediaCaption.js @@ -0,0 +1,17 @@ +const MAX_CAPTION = 120 + +/** + * Für data-shinkan-exercise-media-caption: kurz, ohne Anführungszeichen/HTML. + * @param {string|null|undefined} raw + * @returns {string} + */ +export function sanitizeInlineMediaCaption(raw) { + if (raw == null || typeof raw !== 'string') return '' + let s = raw + .replace(/[\u0000-\u001F\u007F]/g, ' ') + .replace(/["'`<>]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + if (s.length > MAX_CAPTION) s = s.slice(0, MAX_CAPTION) + return s +}