Merge pull request 'Inline Medien' (#24) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 23s

Reviewed-on: #24
This commit is contained in:
Lars 2026-05-08 12:39:27 +02:00
commit c8c40474d1
26 changed files with 2315 additions and 558 deletions

View File

@ -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.470.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="<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.
- **Final festlegen** beim Start der Implementierung (ein Format, nicht mehrere parallele).
- **Kanonisches Markup nach Speichern:** `<span data-shinkan-exercise-media="<numerische exercise_media.id>" class="shinkan-inline-media"></span>`.
- **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.111.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.111.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)

View File

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

View File

@ -0,0 +1,101 @@
"""
Ü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}" data-shinkan-exercise-media-size="medium" '
f'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),
},
)

View File

@ -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:

View 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"

View File

@ -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 = '<p>Vor {{exerciseMedia: 42 }} nach</p>'
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<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]

View File

@ -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",

View File

@ -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:

View File

@ -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;

View File

@ -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 (
<section className="card exercise-detail-section exercise-attachment-media-strip">
<h2>Angehängte Medien</h2>
<p style={{ marginTop: '6px', color: 'var(--text2)', fontSize: '0.88rem' }}>
Hier erscheinen nur Verknüpfungen, die noch nicht im Fließtext eingebettet sind (reine Material-Anhänge).
</p>
<div className="exercise-orphan-media-grid">
{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 (
<article key={m.id} className="exercise-orphan-media-card">
<div className="exercise-orphan-media-card__head">
<ExerciseMediaThumbTile
exerciseId={exerciseId}
media={m}
onOpenPreview={setPreview}
size={88}
/>
<div className="exercise-orphan-media-card__meta">
<strong className="exercise-orphan-media-card__title">{caption}</strong>
<span className="exercise-orphan-media-card__sub">
#{m.id}
{m.embed_platform ? ` · ${m.embed_platform}` : ''}
{m.media_type ? ` · ${m.media_type}` : ''}
</span>
{lc === 'trash_soft' && (
<span className="exercise-orphan-media-card__warn">Papierkorb (Stufe 1)</span>
)}
</div>
</div>
<ExerciseMediaEmbed exerciseId={exerciseId} media={m} layoutSize="medium" />
</article>
)
})}
</div>
{preview && (
<div
role="dialog"
aria-modal="true"
aria-label="Medienvorschau"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.55)',
zIndex: 1001,
overflow: 'auto',
padding: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={() => setPreview(null)}
onKeyDown={(e) => e.key === 'Escape' && setPreview(null)}
>
<div
className="card"
style={{ maxWidth: 720, width: '100%', maxHeight: '90vh', overflow: 'auto' }}
onClick={(e) => e.stopPropagation()}
>
<h3 style={{ marginTop: 0, fontSize: '1.05rem' }}>Vorschau</h3>
{preview.embed_url ? (
<p style={{ fontSize: '14px', wordBreak: 'break-all' }}>
<a href={preview.embed_url} target="_blank" rel="noreferrer">
{preview.embed_url}
</a>
</p>
) : preview.mime_type?.startsWith('video/') || preview.media_type === 'video' ? (
<video
src={resolveExerciseMediaFileUrl(exerciseId, preview)}
controls
style={{ width: '100%', borderRadius: '8px', maxHeight: '70vh' }}
/>
) : preview.mime_type?.startsWith('image/') || preview.media_type === 'image' ? (
<img
alt=""
src={resolveExerciseMediaFileUrl(exerciseId, preview)}
style={{ maxWidth: '100%', borderRadius: '8px', maxHeight: '70vh', objectFit: 'contain' }}
/>
) : (
<p style={{ fontSize: '14px' }}>
<a href={resolveExerciseMediaFileUrl(exerciseId, preview)} target="_blank" rel="noreferrer">
Datei öffnen
</a>
</p>
)}
<div style={{ marginTop: '16px' }}>
<button type="button" className="btn btn-secondary" onClick={() => setPreview(null)}>
Schließen
</button>
</div>
</div>
</div>
)}
</section>
)
}

View File

@ -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 (
<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>
)
}
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
function TagRow({ exercise }) {
const tags = []
@ -112,11 +67,8 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
}
if (!exercise) return null
const resolvedId = exercise.id ?? exerciseId
const meta = metaParts(exercise)
const visibleMedia = (exercise.media || []).filter((m) => {
const lc = String(m.asset_lifecycle_state || 'active').toLowerCase()
return lc !== 'trash_hidden'
})
return (
<div className="exercise-coach-catalog" style={{ fontSize: '0.93rem', lineHeight: 1.5 }}>
@ -132,13 +84,13 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px', letterSpacing: '0.04em' }}>
Kurzbeschreibung
</h3>
<HtmlBlock html={exercise.summary} />
<ExerciseRichTextBlock html={exercise.summary} exerciseId={resolvedId} media={exercise.media} />
</section>
)}
{exercise.goal && (
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
<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>
)}
{(exercise.equipment || []).length > 0 && (
@ -156,32 +108,13 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
{exercise.preparation && (
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
<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>
)}
{exercise.execution && (
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>Ablauf</h3>
<HtmlBlock html={exercise.execution} />
</section>
)}
{visibleMedia.length > 0 && (
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>
Medien
</h3>
{visibleMedia.map((m) => (
<div key={m.id} style={{ marginBottom: '12px' }}>
<strong style={{ fontSize: '0.9rem' }}>{m.title || m.original_filename || m.media_type}</strong>
{String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_soft' && (
<p style={{ fontSize: '0.75rem', color: 'var(--danger)', margin: '4px 0 0' }}>
Hinweis: Dieses Medium ist im Papierkorb und steht künftig nicht mehr zur Verfügung.
</p>
)}
{m.description && <p style={{ color: 'var(--text2)', fontSize: '0.82rem', marginTop: '4px' }}>{m.description}</p>}
<MediaBlock media={m} exerciseId={exercise.id ?? exerciseId} />
</div>
))}
<ExerciseRichTextBlock html={exercise.execution} exerciseId={resolvedId} media={exercise.media} />
</section>
)}
{exercise.trainer_notes && (
@ -189,7 +122,7 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>
Hinweise Trainer (Katalog)
</h3>
<HtmlBlock html={exercise.trainer_notes} />
<ExerciseRichTextBlock html={exercise.trainer_notes} exerciseId={resolvedId} media={exercise.media} />
</section>
)}
{exerciseId != null && (

View File

@ -0,0 +1,132 @@
/**
* Modal: Embed-URL als exercise_media anlegen und §11-Platzhalter einfügen.
*/
import React, { useEffect, useState } from 'react'
import api from '../utils/api'
import {
INLINE_MEDIA_SIZES,
DEFAULT_INLINE_MEDIA_SIZE,
sanitizeInlineMediaSize,
} from '../constants/inlineExerciseMedia'
import { sanitizeInlineMediaCaption } from '../utils/inlineMediaCaption'
/**
* @param {{
* open: boolean,
* onClose: () => void,
* exerciseId: number,
* onMediaListChanged: () => Promise<void>,
* onInserted: (exerciseMediaId: number, displaySize: string, caption?: string) => void,
* }} props
*/
export default function ExerciseInlineEmbedModal({
open,
onClose,
exerciseId,
onMediaListChanged,
onInserted,
}) {
const [url, setUrl] = useState('')
const [title, setTitle] = useState('')
const [displaySize, setDisplaySize] = useState(DEFAULT_INLINE_MEDIA_SIZE)
const [busy, setBusy] = useState(false)
useEffect(() => {
if (!open) return
setUrl('')
setTitle('')
setDisplaySize(DEFAULT_INLINE_MEDIA_SIZE)
}, [open])
const submit = async () => {
const u = url.trim()
if (!u) {
alert('Bitte eine Embed-URL eingeben (https://…).')
return
}
const size = sanitizeInlineMediaSize(displaySize)
const fd = new FormData()
fd.append('embed_url', u)
fd.append('media_type', 'video')
fd.append('title', title.trim())
fd.append('description', '')
fd.append('context', 'ablauf')
fd.append('is_primary', 'false')
setBusy(true)
try {
const row = await api.uploadExerciseMedia(exerciseId, fd)
const mid = row?.id
if (mid == null) {
throw new Error('Antwort ohne exercise_media-ID')
}
await onMediaListChanged()
const cap = sanitizeInlineMediaCaption(
title.trim() || u.replace(/^https?:\/\//i, '').slice(0, 96),
)
onInserted(Number(mid), size, cap)
onClose()
} catch (e) {
alert(e.message || String(e))
} finally {
setBusy(false)
}
}
if (!open) return null
return (
<div className="admin-modal-backdrop" role="presentation" onClick={(e) => e.target === e.currentTarget && !busy && onClose()}>
<div
className="admin-modal-sheet"
role="dialog"
aria-modal="true"
aria-labelledby="rte-inline-embed-title"
style={{ maxWidth: '480px', width: '100%' }}
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<h3 id="rte-inline-embed-title" className="admin-modal-sheet__title">
Embed im Textfeld
</h3>
<button type="button" className="btn btn-secondary admin-modal-sheet__close" disabled={busy} onClick={onClose}>
Schließen
</button>
</div>
<div style={{ padding: '14px 16px' }}>
<label className="form-label">Embed-URL</label>
<input
type="url"
className="form-input"
placeholder="https://…"
value={url}
onChange={(e) => setUrl(e.target.value)}
disabled={busy}
/>
<label className="form-label" style={{ marginTop: '12px' }}>
Titel (optional)
</label>
<input
type="text"
className="form-input"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={busy}
/>
<label className="form-label" style={{ marginTop: '12px' }}>
Darstellung im Text
</label>
<select className="form-input" value={displaySize} onChange={(e) => setDisplaySize(e.target.value)} disabled={busy}>
{INLINE_MEDIA_SIZES.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<button type="button" className="btn btn-primary btn-full" style={{ marginTop: '16px' }} disabled={busy} onClick={submit}>
{busy ? 'Speichern…' : 'Hinzufügen & in Text einfügen'}
</button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,396 @@
/**
* Modal: Medium aus Archiv verknüpfen oder neue Datei hochladen, dann Inline-Platzhalter §11 einfügen.
*/
import React, { useEffect, useState, useCallback, useMemo } from 'react'
import api from '../utils/api'
import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
import {
INLINE_MEDIA_SIZES,
DEFAULT_INLINE_MEDIA_SIZE,
sanitizeInlineMediaSize,
} from '../constants/inlineExerciseMedia'
import { sanitizeInlineMediaCaption } from '../utils/inlineMediaCaption'
function RtePickerAssetThumb({ asset }) {
const id = asset.id
const src = resolveMediaAssetFileUrl(id)
const mt = (asset.mime_type || '').toLowerCase()
if (mt.startsWith('image/') && src) {
return <img alt="" src={src} className="rte-inline-asset-tile__thumb-img" />
}
if (mt.startsWith('video/') && src) {
return (
<video
key={`v-${id}`}
className="rte-inline-asset-tile__thumb-video"
src={src}
muted
playsInline
preload="metadata"
onLoadedMetadata={(e) => {
try {
const el = e.currentTarget
const d = el.duration
el.currentTime = Number.isFinite(d) && d > 0 ? Math.min(0.05, d * 0.01) : 0.05
} catch (_) {
/* ignore */
}
}}
/>
)
}
const nameLow = String(asset.original_filename || '').toLowerCase()
if (mt.includes('pdf') || nameLow.endsWith('.pdf')) {
return <span className="rte-inline-asset-tile__thumb-fallback">PDF</span>
}
return <span className="rte-inline-asset-tile__thumb-fallback">Datei</span>
}
/** MIME/Dateiname → Übungs-media_type */
function inferExerciseMediaType(file) {
if (!file) return 'image'
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 'image'
}
/**
* @param {{
* open: boolean,
* onClose: () => void,
* exerciseId: number,
* linkedExerciseMedia?: object[],
* onMediaListChanged: () => Promise<void>,
* onInserted: (exerciseMediaId: number, displaySize: string, caption?: string) => void,
* }} props
*/
export default function ExerciseInlineFileMediaModal({
open,
onClose,
exerciseId,
linkedExerciseMedia = [],
onMediaListChanged,
onInserted,
}) {
const [tab, setTab] = useState('library')
const [q, setQ] = useState('')
const [loading, setLoading] = useState(false)
const [items, setItems] = useState([])
const [err, setErr] = useState(null)
const [selectedAssetId, setSelectedAssetId] = useState(null)
const [busy, setBusy] = useState(false)
const [uploadFile, setUploadFile] = useState(null)
const [uploadTitle, setUploadTitle] = useState('')
const [displaySize, setDisplaySize] = useState(DEFAULT_INLINE_MEDIA_SIZE)
const [uploadInputKey, setUploadInputKey] = useState(0)
const assetToExerciseMedia = useMemo(() => {
const m = new Map()
for (const row of linkedExerciseMedia || []) {
const aid = row?.media_asset_id
if (aid != null) m.set(Number(aid), row)
}
return m
}, [linkedExerciseMedia])
const loadAssets = useCallback(async () => {
setLoading(true)
setErr(null)
try {
const res = await api.listMediaAssets({
q: q.trim() || undefined,
limit: 48,
lifecycle: 'active',
})
setItems(Array.isArray(res.items) ? res.items : [])
} catch (e) {
setErr(e.message || String(e))
setItems([])
} finally {
setLoading(false)
}
}, [q])
useEffect(() => {
if (!open) return undefined
setTab('library')
setSelectedAssetId(null)
setUploadFile(null)
setUploadTitle('')
setUploadInputKey((k) => k + 1)
setDisplaySize(DEFAULT_INLINE_MEDIA_SIZE)
setErr(null)
const t = setTimeout(loadAssets, 280)
return () => clearTimeout(t)
}, [open])
useEffect(() => {
if (!open || tab !== 'library') return undefined
const t = setTimeout(loadAssets, 300)
return () => clearTimeout(t)
}, [q, open, tab, loadAssets])
const handleLinkSelected = async () => {
if (!selectedAssetId) {
alert('Bitte ein Archiv-Medium auswählen.')
return
}
const size = sanitizeInlineMediaSize(displaySize)
const assetMeta = items.find((x) => x.id === selectedAssetId)
const capFromExisting = (row) =>
sanitizeInlineMediaCaption(row?.original_filename || row?.title || assetMeta?.original_filename || '')
const existing = assetToExerciseMedia.get(Number(selectedAssetId))
if (existing?.id != null) {
onInserted(Number(existing.id), size, capFromExisting(existing))
onClose()
return
}
setBusy(true)
setErr(null)
try {
const row = await api.attachExerciseMediaFromAsset(exerciseId, {
media_asset_id: selectedAssetId,
title: '',
description: '',
context: 'ablauf',
is_primary: false,
})
const mid = row?.id
if (mid == null) {
throw new Error('Antwort ohne exercise_media-ID')
}
await onMediaListChanged()
onInserted(
Number(mid),
size,
sanitizeInlineMediaCaption(assetMeta?.original_filename || ''),
)
onClose()
} catch (e) {
const msg = e.message || String(e)
setErr(msg)
} finally {
setBusy(false)
}
}
const handleUploadAndInsert = async () => {
if (!uploadFile) {
alert('Bitte eine Datei wählen.')
return
}
const size = sanitizeInlineMediaSize(displaySize)
const inferred = inferExerciseMediaType(uploadFile)
const fd = new FormData()
fd.append('file', uploadFile)
fd.append('media_type', inferred)
fd.append('title', uploadTitle.trim())
fd.append('description', '')
fd.append('context', 'ablauf')
fd.append('is_primary', 'false')
setBusy(true)
setErr(null)
try {
const row = await api.uploadExerciseMedia(exerciseId, fd)
const mid = row?.id
if (mid == null) {
throw new Error('Antwort ohne exercise_media-ID')
}
await onMediaListChanged()
const cap = sanitizeInlineMediaCaption(
uploadTitle.trim() || uploadFile.name || '',
)
onInserted(Number(mid), size, cap)
setUploadFile(null)
setUploadTitle('')
setUploadInputKey((k) => k + 1)
onClose()
} catch (e) {
if (e.code === 'MEDIA_ASSET_IN_TRASH' && e.payload?.media_asset_id != null) {
alert(
'Dieselbe Datei existiert bereits im Papierkorb — bitte in der Medienbibliothek reaktivieren oder eine andere Datei wählen.',
)
} else {
alert(e.message || String(e))
}
setErr(e.message || String(e))
} finally {
setBusy(false)
}
}
const selectedLinked = selectedAssetId != null && assetToExerciseMedia.has(Number(selectedAssetId))
if (!open) return null
return (
<div className="admin-modal-backdrop" role="presentation" onClick={(e) => e.target === e.currentTarget && !busy && onClose()}>
<div
className="admin-modal-sheet rte-inline-media-modal"
role="dialog"
aria-modal="true"
aria-labelledby="rte-inline-file-title"
style={{
maxWidth: '560px',
width: '100%',
maxHeight: '90vh',
display: 'flex',
flexDirection: 'column',
}}
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<h3 id="rte-inline-file-title" className="admin-modal-sheet__title">
Medium im Textfeld
</h3>
<button type="button" className="btn btn-secondary admin-modal-sheet__close" disabled={busy} onClick={onClose}>
Schließen
</button>
</div>
<div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border)', display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
type="button"
className={`btn btn-secondary ${tab === 'library' ? 'rte-tab--active' : ''}`}
style={{ fontSize: '13px' }}
onClick={() => setTab('library')}
disabled={busy}
>
Aus Mediathek
</button>
<button
type="button"
className={`btn btn-secondary ${tab === 'upload' ? 'rte-tab--active' : ''}`}
style={{ fontSize: '13px' }}
onClick={() => setTab('upload')}
disabled={busy}
>
Neu hochladen
</button>
</div>
<div style={{ overflowY: 'auto', flex: 1, padding: '12px 14px' }}>
{err && !loading ? (
<p style={{ color: 'var(--danger)', marginTop: '0', marginBottom: '12px', fontSize: '0.9rem' }}>{err}</p>
) : null}
{tab === 'library' && (
<>
<label className="form-label">Suche in der Bibliothek</label>
<input
className="form-input"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Name, Tag, © …"
disabled={busy}
/>
{loading ? <p style={{ color: 'var(--text3)', marginTop: '12px' }}>Laden</p> : null}
<div className="rte-inline-asset-grid" style={{ marginTop: '14px' }}>
{items.map((it) => {
const id = it.id
const selected = selectedAssetId === id
const label = it.original_filename || it.copyright_notice || `Archiv #${id}`
const linked = assetToExerciseMedia.has(Number(id))
return (
<button
key={id}
type="button"
className={`rte-inline-asset-tile${selected ? ' rte-inline-asset-tile--selected' : ''}`}
onClick={() => setSelectedAssetId(id)}
disabled={busy}
>
<div className="rte-inline-asset-tile__thumb" aria-hidden>
<RtePickerAssetThumb asset={it} />
</div>
{linked ? (
<span className="rte-inline-asset-tile__badge">Bereits verknüpft</span>
) : null}
<span className="rte-inline-asset-tile__meta">
{(it.mime_type || '').split('/')[0] || 'datei'}
</span>
<span className="rte-inline-asset-tile__name">{label}</span>
</button>
)
})}
</div>
{!loading && items.length === 0 ? (
<p style={{ color: 'var(--text3)', marginTop: '12px' }}>Keine Treffer Suche anpassen oder Neu hochladen.</p>
) : null}
</>
)}
{tab === 'upload' && (
<>
<label className="form-label">Datei</label>
<div className="rte-inline-file-row">
<input
key={uploadInputKey}
id="rte-inline-file-upload-input"
type="file"
accept="image/*,video/*,application/pdf"
className="rte-inline-file-input-hidden"
disabled={busy}
onChange={(e) => {
const f = e.target.files?.[0] || null
setUploadFile(f)
}}
/>
<label htmlFor="rte-inline-file-upload-input" className="btn btn-secondary rte-inline-file-pick-btn">
Datei auswählen
</label>
<span className="rte-inline-file-name" title={uploadFile?.name || ''}>
{uploadFile ? uploadFile.name : 'Keine Datei ausgewählt'}
</span>
</div>
<label className="form-label" style={{ marginTop: '12px' }}>
Titel (optional)
</label>
<input
className="form-input"
value={uploadTitle}
onChange={(e) => setUploadTitle(e.target.value)}
disabled={busy}
/>
</>
)}
</div>
<div style={{ padding: '12px 14px', borderTop: '1px solid var(--border)', flexShrink: 0 }}>
<div className="form-row" style={{ marginBottom: '12px' }}>
<label className="form-label" style={{ marginBottom: '4px' }}>
Darstellung im Text
</label>
<select className="form-input" value={displaySize} onChange={(e) => setDisplaySize(e.target.value)} disabled={busy}>
{INLINE_MEDIA_SIZES.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
{tab === 'library' ? (
<button
type="button"
className="btn btn-primary btn-full"
disabled={busy || !selectedAssetId}
onClick={handleLinkSelected}
>
{busy ? 'Bitte warten…' : selectedLinked ? 'In Text einfügen (bereits verknüpft)' : 'Verknüpfen & in Text einfügen'}
</button>
) : (
<button type="button" className="btn btn-primary btn-full" disabled={busy || !uploadFile} onClick={handleUploadAndInsert}>
{busy ? 'Hochladen…' : 'Hochladen & in Text einfügen'}
</button>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,71 @@
import React from 'react'
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
import { sanitizeInlineMediaSize } from '../constants/inlineExerciseMedia'
/**
* Ein ausgeliefertes exercise_media für Übungslisten (Liste + Inline gleiche Darstellung).
* @param {{ media: object, exerciseId: number, layoutSize?: string }} props
*/
export default function ExerciseMediaEmbed({ exerciseId, media, layoutSize = 'medium' }) {
const sz = sanitizeInlineMediaSize(layoutSize)
const box =
sz === 'small'
? { maxWidth: 'min(280px, 33vw)', marginTop: '0.5rem' }
: sz === 'full'
? { maxWidth: '100%', marginTop: '0.5rem' }
: { maxWidth: 'min(560px, 85vw)', marginTop: '0.5rem' }
if (!media || exerciseId == null) return null
if (media.embed_url) {
return (
<div
style={{
...box,
wordBreak: 'break-word',
overflowWrap: 'anywhere',
fontSize: sz === 'small' ? '0.88rem' : undefined,
}}
>
<a href={media.embed_url} target="_blank" rel="noreferrer">
{media.title?.trim() || media.embed_url}
</a>
{media.embed_platform && (
<span style={{ color: 'var(--text2)', marginLeft: '0.35rem', fontSize: '0.82rem', display: 'inline' }}>
({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 (
<div style={box}>
<img
src={src}
alt={media.title || media.original_filename || ''}
style={{ width: '100%', maxWidth: '100%', height: 'auto', borderRadius: '8px', display: 'block' }}
/>
</div>
)
}
if (media.media_type === 'video' || (media.mime_type && media.mime_type.startsWith('video/'))) {
return (
<div style={box}>
<video
src={src}
controls
style={{ width: '100%', maxWidth: '100%', borderRadius: '8px', verticalAlign: 'top' }}
/>
</div>
)
}
return (
<div style={box}>
<a href={src} target="_blank" rel="noreferrer" style={{ display: 'inline-block', marginTop: '0.25rem' }}>
{media.title || media.original_filename || 'Datei öffnen'}
</a>
</div>
)
}

View File

@ -0,0 +1,68 @@
/**
* Kachelvorschau: Video nutzt ersten Frame (metadata), Bild = img, Embed = Label.
*/
import React from 'react'
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
export default function ExerciseMediaThumbTile({ exerciseId, media, onOpenPreview, size = 72 }) {
const src = !media.embed_url ? resolveExerciseMediaFileUrl(exerciseId, media) : null
const commonStyle = {
width: '100%',
height: '100%',
objectFit: 'cover',
}
return (
<div
role="button"
tabIndex={0}
title="Vorschau"
onClick={() => onOpenPreview(media)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onOpenPreview(media)
}
}}
style={{
width: size,
height: size,
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 ? (
<span style={{ fontSize: '11px', padding: '4px', color: 'var(--text2)', textAlign: 'center' }}>
{media.embed_platform || 'Embed'}
</span>
) : (media.mime_type?.startsWith('image/') || media.media_type === 'image') && src ? (
<img alt="" src={src} style={commonStyle} />
) : (media.mime_type?.startsWith('video/') || media.media_type === 'video') && src ? (
<video
src={src}
muted
playsInline
preload="metadata"
style={{ ...commonStyle, pointerEvents: 'none' }}
onLoadedMetadata={(e) => {
try {
const el = e.currentTarget
const d = el.duration
el.currentTime = Number.isFinite(d) && d > 0 ? Math.min(0.05, d * 0.01) : 0.05
} catch {
/* ignore */
}
}}
/>
) : (
<span style={{ fontSize: '11px', color: 'var(--text2)' }}>Datei</span>
)}
</div>
)
}

View File

@ -4,15 +4,7 @@
import React, { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import { sanitizeTrainerHtml } from '../utils/htmlUtils'
function HtmlBlock({ html, className = '' }) {
if (!html || !String(html).trim()) return null
const safe = sanitizeTrainerHtml(html)
return (
<div className={`rich-text-content ${className}`} dangerouslySetInnerHTML={{ __html: safe }} />
)
}
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
function TagMini({ exercise }) {
const parts = []
@ -129,7 +121,7 @@ export default function ExercisePeekModal({
</div>
{variant.description ? (
<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>
) : null}
{variant.execution_changes ? (
@ -137,14 +129,14 @@ export default function ExercisePeekModal({
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', marginBottom: 6 }}>
Durchführung (Variante)
</h4>
<HtmlBlock html={variant.execution_changes} />
<ExerciseRichTextBlock html={variant.execution_changes} exerciseId={exercise.id} media={exercise.media} />
</div>
) : null}
</div>
) : null}
{exercise.summary && (
<div style={{ fontSize: '0.95rem', color: 'var(--text2)' }}>
<HtmlBlock html={exercise.summary} />
<ExerciseRichTextBlock html={exercise.summary} exerciseId={exercise.id} media={exercise.media} />
</div>
)}
<TagMini exercise={exercise} />
@ -154,25 +146,25 @@ export default function ExercisePeekModal({
{exercise.goal && (
<>
<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 && (
<>
<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 && (
<>
<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 && (
<>
<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} />
</>
)}
</>

View File

@ -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 (
<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 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 (
<span key={key} className={wrapClass} style={{ display: 'inline-block', verticalAlign: 'top' }}>
{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} layoutSize={layoutSize} />
</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>
}

View File

@ -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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
}
function buildInlineExerciseMediaHtml(mediaId, displaySize = 'medium', caption = '') {
const sid = parseInt(String(mediaId), 10)
if (!Number.isFinite(sid) || sid < 1) return null
const sz = ['small', 'medium', 'full'].includes(displaySize) ? displaySize : 'medium'
const cap = sanitizeInlineMediaCaption(caption)
let attrs = `data-shinkan-exercise-media="${sid}" data-shinkan-exercise-media-size="${sz}" class="shinkan-inline-media"`
if (cap) attrs += ` data-shinkan-exercise-media-caption="${escapeHtmlAttr(cap)}"`
return `<span ${attrs}>\u2060</span>`
}
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
<button type="button" className="rte-btn" title="Link einfügen" onMouseDown={onLink}>
Link
</button>
{showInlineToolbar ? (
<>
<button
type="button"
className="rte-btn"
title="Datei aus Mediathek oder neu hochladen, in den Text einfügen"
onMouseDown={stashRangeAndOpen(() => setFileModalOpen(true))}
>
Medien im Text
</button>
<button
type="button"
className="rte-btn"
title="Embed-URL hinzufügen und im Text einfügen"
onMouseDown={stashRangeAndOpen(() => setEmbedModalOpen(true))}
>
Embed im Text
</button>
</>
) : null}
<button
type="button"
className="rte-btn"
@ -158,7 +437,31 @@ export default function RichTextEditor({ value, onChange, placeholder, minHeight
sync()
}}
onInput={sync}
onKeyDown={onEditorKeyDown}
onDragEnter={onEditorDragOver}
onDragOver={onEditorDragOver}
onDrop={onEditorDrop}
/>
{showInlineToolbar ? (
<ExerciseInlineFileMediaModal
open={fileModalOpen}
onClose={() => setFileModalOpen(false)}
exerciseId={Number(inlineExerciseId)}
linkedExerciseMedia={linkedExerciseMedia}
onMediaListChanged={refreshExerciseMedia}
onInserted={(mid, sz, cap) => finalizeInsertFromModal(mid, sz, cap)}
/>
) : null}
{showInlineToolbar ? (
<ExerciseInlineEmbedModal
open={embedModalOpen}
onClose={() => setEmbedModalOpen(false)}
exerciseId={Number(inlineExerciseId)}
onMediaListChanged={refreshExerciseMedia}
onInserted={(mid, sz, cap) => finalizeInsertFromModal(mid, sz, cap)}
/>
) : null}
</div>
)
}

View File

@ -0,0 +1,14 @@
/** Inline-Medium im Fließtext §11 — Darstellung (CSS + data-shinkan-exercise-media-size). */
export const INLINE_MEDIA_SIZES = [
{ value: 'small', label: 'Klein (~33 %)' },
{ value: 'medium', label: 'Mittel (~66 %)', default: true },
{ value: 'full', label: 'Volle Breite' },
]
export const DEFAULT_INLINE_MEDIA_SIZE = 'medium'
export function sanitizeInlineMediaSize(v) {
const s = String(v || '').toLowerCase().trim()
if (s === 'small' || s === 'medium' || s === 'full') return s
return DEFAULT_INLINE_MEDIA_SIZE
}

View File

@ -1,57 +1,10 @@
import React, { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { Link, useNavigate, useParams, useLocation } from 'react-router-dom'
import api from '../utils/api'
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
import { sanitizeTrainerHtml } from '../utils/htmlUtils'
import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaStrip'
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 }) {
const tags = []
;(exercise.focus_areas || []).forEach((f) => {
@ -100,6 +53,7 @@ function metaParts(exercise) {
function ExerciseDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
const location = useLocation()
const [exercise, setExercise] = useState(null)
const [error, setError] = useState(null)
const [loading, setLoading] = useState(true)
@ -151,27 +105,26 @@ function ExerciseDetailPage() {
if (!exercise) return null
const meta = metaParts(exercise)
const visibleMedia = (exercise.media || []).filter((m) => {
const lc = String(m.asset_lifecycle_state || 'active').toLowerCase()
return lc !== 'trash_hidden'
})
const fromExerciseEdit = location.state?.fromExerciseEdit === true
return (
<div className="exercise-detail-shell" style={{ padding: '12px 12px 24px' }}>
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap' }}>
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}>
Übersicht
</button>
<Link to={`/exercises/${exercise.id}/edit`} className="btn btn-primary">
Bearbeiten
</Link>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginLeft: 'auto' }}>
<Link to={`/exercises/${exercise.id}/edit`} className="btn btn-primary">
{fromExerciseEdit ? 'Zurück zur Bearbeitung' : 'Bearbeiten'}
</Link>
</div>
</div>
<div className="card exercise-detail-section">
<h1 style={{ margin: 0, fontSize: '1.35rem', lineHeight: 1.25 }}>{exercise.title}</h1>
{exercise.summary && (
<div style={{ marginTop: '10px', color: 'var(--text2)', fontSize: '15px' }}>
<HtmlBlock html={exercise.summary} />
<ExerciseRichTextBlock html={exercise.summary} exerciseId={exercise.id} media={exercise.media} />
</div>
)}
<TagRow exercise={exercise} />
@ -186,7 +139,7 @@ function ExerciseDetailPage() {
{exercise.goal && (
<section className="card exercise-detail-section">
<h2>Ziel</h2>
<HtmlBlock html={exercise.goal} />
<ExerciseRichTextBlock html={exercise.goal} exerciseId={exercise.id} media={exercise.media} />
</section>
)}
@ -204,39 +157,23 @@ function ExerciseDetailPage() {
{exercise.preparation && (
<section className="card exercise-detail-section">
<h2>Vorbereitung</h2>
<HtmlBlock html={exercise.preparation} />
<ExerciseRichTextBlock html={exercise.preparation} exerciseId={exercise.id} media={exercise.media} />
</section>
)}
{exercise.execution && (
<section className="card exercise-detail-section">
<h2>Ablauf</h2>
<HtmlBlock html={exercise.execution} />
<ExerciseRichTextBlock html={exercise.execution} exerciseId={exercise.id} media={exercise.media} />
</section>
)}
{visibleMedia.length > 0 && (
<section className="card exercise-detail-section">
<h2>Medien</h2>
{visibleMedia.map((m) => (
<div key={m.id} style={{ marginBottom: '1.25rem' }}>
<strong style={{ fontSize: '15px' }}>{m.title || m.original_filename || m.media_type}</strong>
{String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_soft' && (
<p style={{ fontSize: '12px', color: 'var(--danger)', margin: '6px 0 0' }}>
Hinweis: Dieses Medium ist im Papierkorb und steht künftig nicht mehr zur Verfügung.
</p>
)}
{m.description && <p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{m.description}</p>}
<MediaBlock media={m} exerciseId={exercise.id} />
</div>
))}
</section>
)}
<ExerciseAttachmentMediaStrip exercise={exercise} exerciseId={exercise.id} />
{exercise.trainer_notes && (
<section className="card exercise-detail-section">
<h2>Hinweise für Trainer</h2>
<HtmlBlock html={exercise.trainer_notes} />
<ExerciseRichTextBlock html={exercise.trainer_notes} exerciseId={exercise.id} media={exercise.media} />
</section>
)}
@ -307,7 +244,11 @@ function ExerciseDetailPage() {
{v.description && <p style={{ color: 'var(--text2)', marginTop: '4px' }}>{v.description}</p>}
{v.execution_changes && (
<div style={{ marginTop: '8px' }}>
<HtmlBlock html={v.execution_changes} />
<ExerciseRichTextBlock
html={v.execution_changes}
exerciseId={exercise.id}
media={exercise.media}
/>
</div>
)}
</div>

View File

@ -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 (
<div
role="button"
tabIndex={0}
title="Vorschau"
onClick={() => 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 ? (
<span style={{ fontSize: '11px', padding: '4px', color: 'var(--text2)', textAlign: 'center' }}>
{media.embed_platform || 'Embed'}
</span>
) : (media.mime_type?.startsWith('image/') || media.media_type === 'image') && src ? (
<img alt="" src={src} style={commonStyle} />
) : (media.mime_type?.startsWith('video/') || media.media_type === 'video') && src ? (
<video
src={src}
muted
playsInline
preload="metadata"
style={{ ...commonStyle, pointerEvents: 'none' }}
onLoadedMetadata={(e) => {
try {
const el = e.currentTarget
const d = el.duration
el.currentTime = Number.isFinite(d) && d > 0 ? Math.min(0.05, d * 0.01) : 0.05
} catch (_) {
/* ignore */
}
}}
/>
) : (
<span style={{ fontSize: '11px', color: 'var(--text2)' }}>Datei</span>
)}
</div>
)
}
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 (
<>
<div className="form-row">
@ -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}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
@ -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
</button>
@ -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}
/>
</div>
@ -1050,6 +940,9 @@ function ExerciseFormPage() {
onChange={(html) => updateFormField('goal', html)}
placeholder="Trainingsziel"
minHeight="120px"
inlineExerciseId={isEdit ? exerciseId : null}
linkedExerciseMedia={isEdit ? mediaList : []}
onExerciseMediaListChanged={refreshMedia}
/>
</div>
@ -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}
/>
</div>
@ -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}
/>
</div>
@ -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}
/>
</div>
@ -1371,9 +1273,15 @@ function ExerciseFormPage() {
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Neue Variante</h3>
<ExerciseVariantFields
row={variantDraft}
onPatch={(patch) => 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}
/>
<button type="submit" className="btn btn-primary" style={{ marginTop: '10px' }} disabled={variantBusy}>
{variantBusy ? 'Anlegen…' : 'Variante anlegen'}
@ -1440,6 +1348,9 @@ function ExerciseFormPage() {
onPatch={(patch) => updateVariantField(selectedVariantForEdit.id, patch)}
prerequisiteOthers={variants.filter((o) => o.id !== selectedVariantForEdit.id)}
rteMinHeight="110px"
inlineExerciseId={isEdit ? exerciseId : null}
linkedExerciseMedia={isEdit ? mediaList : []}
onExerciseMediaListChanged={refreshMedia}
/>
</div>
)}
@ -1468,8 +1379,13 @@ function ExerciseFormPage() {
{isEdit && (
<div className="card" style={{ marginTop: '16px' }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Medien</h2>
<p style={{ color: 'var(--text2)', fontSize: '13px' }}>
Datei oder Embed (YouTube, Vimeo, Instagram, TikTok). Max. 10 pro Übung.
<p style={{ color: 'var(--text2)', fontSize: '13px', marginBottom: '6px' }}>
Neue Uploads oder Embeds über die Textfeld-Symbolleiste (Medien im Text / Embed im Text). Hier
verwaltest du Verknüpfungen Kachel in ein Textfeld ziehen, um sie an der Cursorposition einzufügen
(mittlere Darstellung).
</p>
<p style={{ color: 'var(--text3)', fontSize: '12px', marginTop: 0 }}>
Max. 10 Medien pro Übung.
</p>
<div
style={{
@ -1487,193 +1403,125 @@ function ExerciseFormPage() {
Medienbibliothek
</Link>
</div>
<div style={{ display: 'grid', gap: '12px', marginTop: '12px' }}>
<div>
<label className="form-label">Dateien</label>
<input
type="file"
multiple
accept="image/*,video/*,application/pdf"
onChange={(e) => {
setMediaFiles(Array.from(e.target.files || []))
e.target.value = ''
}}
/>
{mediaFiles.length > 0 && (
<div style={{ fontSize: '0.875rem', color: 'var(--text2)', marginTop: '6px' }}>
{mediaFiles.length} Datei(en): {mediaFiles.map((f) => f.name).join(', ')}
</div>
)}
<div className="form-row" style={{ marginTop: '8px' }}>
<select className="form-input" value={mediaType} onChange={(e) => setMediaType(e.target.value)}>
<option value="image">Typ-Fallback: Bild</option>
<option value="video">Typ-Fallback: Video</option>
<option value="document">Typ-Fallback: PDF</option>
</select>
<input
type="text"
className="form-input"
placeholder="Titel (optional)"
value={mediaTitle}
onChange={(e) => setMediaTitle(e.target.value)}
style={{ marginTop: '8px' }}
/>
<select
className="form-input"
value={mediaContext}
onChange={(e) => setMediaContext(e.target.value)}
style={{ marginTop: '8px' }}
>
<option value="ablauf">Ablauf</option>
<option value="detail">Detail</option>
<option value="trainer_hint">Trainer-Hinweis</option>
</select>
</div>
<button type="button" className="btn btn-secondary" style={{ marginTop: '8px' }} onClick={handleUploadFile}>
Hochladen
</button>
</div>
<div>
<label className="form-label">Embed-URL</label>
<input
type="url"
className="form-input"
placeholder="https://…"
value={embedUrl}
onChange={(e) => setEmbedUrl(e.target.value)}
/>
<input
type="text"
className="form-input"
placeholder="Titel (optional)"
value={embedTitle}
onChange={(e) => setEmbedTitle(e.target.value)}
style={{ marginTop: '8px' }}
/>
<button type="button" className="btn btn-secondary" style={{ marginTop: '8px' }} onClick={handleAddEmbed}>
Embed hinzufügen
</button>
</div>
</div>
{mediaList.length > 0 && (
<ul style={{ marginTop: '12px', paddingLeft: '0', listStyle: 'none' }}>
{mediaList.map((m, idx) => (
<li
key={m.id}
className="card"
style={{
marginBottom: '10px',
padding: '10px 12px',
border: '1px solid var(--border)',
display: 'flex',
gap: '12px',
alignItems: 'flex-start',
}}
>
<ExerciseMediaThumbTile exerciseId={exerciseId} media={m} onOpenPreview={setMediaPreview} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
#{idx + 1} · {m.media_type}
{m.embed_platform ? ` · ${m.embed_platform}` : ''}
</span>
{mediaList.length > 1 && (
<>
<ul className="exercise-edit-media-strip">
{mediaList.map((m, idx) => {
const cap =
(m.title || '').trim() ||
(m.original_filename || '').trim() ||
(m.embed_url ? String(m.embed_url).replace(/^https?:\/\//i, '').slice(0, 80) : '')
const sub = [m.media_type, m.embed_platform].filter(Boolean).join(' · ') || 'Medium'
const payloadCaption = (
[m.title, m.original_filename].find((x) => typeof x === 'string' && x.trim()) || ''
).trim()
return (
<li key={m.id} className="exercise-edit-media-strip__item">
<div className="exercise-edit-media-strip__lead">
{!m.embed_url ? (
<ExerciseMediaThumbTile exerciseId={exerciseId} media={m} onOpenPreview={setMediaPreview} size={76} />
) : (
<div
className="exercise-edit-media-strip__embed-badge exercise-edit-media-strip__embed-badge--solo"
aria-hidden
>
{m.embed_platform || 'Embed'}
</div>
)}
<div
className="exercise-edit-media-strip__handle"
title="Mit Drag und Drop in ein Textfeld ziehen"
draggable
onDragStart={(e) => {
try {
e.dataTransfer.setData(
SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
buildExerciseMediaDragPayload(m.id, payloadCaption),
)
e.dataTransfer.effectAllowed = 'copy'
} catch (_) {
/* ignore */
}
}}
>
<span className="exercise-edit-media-strip__handle-text"> Ziehen</span>
</div>
</div>
<div className="exercise-edit-media-strip__body">
<div className="exercise-edit-media-strip__headline">
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
#{m.id} · {sub}
</span>
</div>
<div style={{ fontSize: '13px', color: 'var(--text2)', lineHeight: 1.35 }}>{cap || '—'}</div>
<div className="exercise-edit-media-strip__toolbar">
<input
type="text"
className="form-input exercise-edit-media-strip__title"
placeholder="Titel (wird in der Vorschau und im Platzhalter genutzt)"
value={(mediaFields[m.id] || {}).title ?? ''}
onChange={(e) =>
setMediaFields((prev) => ({
...prev,
[m.id]: {
title: e.target.value,
},
}))
}
/>
</div>
<div className="exercise-edit-media-strip__actions">
{mediaList.length > 1 && (
<>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }}
disabled={idx === 0}
onClick={() => moveMediaRow(idx, -1)}
title="Nach oben"
>
</button>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }}
disabled={idx >= mediaList.length - 1}
onClick={() => moveMediaRow(idx, 1)}
title="Nach unten"
>
</button>
</>
)}
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '2px 8px' }}
disabled={idx === 0}
onClick={() => moveMediaRow(idx, -1)}
title="Nach oben"
className="btn btn-primary"
style={{ fontSize: '12px', padding: '4px 10px' }}
disabled={mediaSavingId === m.id}
onClick={() => saveMediaMeta(m.id)}
>
{mediaSavingId === m.id ? '…' : 'Speichern'}
</button>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '2px 8px' }}
disabled={idx >= mediaList.length - 1}
onClick={() => moveMediaRow(idx, 1)}
title="Nach unten"
style={{ fontSize: '12px', padding: '4px 10px' }}
onClick={() => handleDeleteMedia(m.id)}
>
Entfernen
</button>
</>
)}
</div>
<div
style={{
fontSize: '12px',
color: 'var(--text2)',
marginTop: '6px',
wordBreak: 'break-word',
lineHeight: 1.35,
}}
>
{(m.original_filename || '').trim() ||
(m.title || '').trim() ||
(m.embed_url ? m.embed_url : '') ||
'—'}
</div>
<div className="form-row" style={{ marginTop: '8px', display: 'grid', gap: '8px' }}>
<input
type="text"
className="form-input"
placeholder="Titel"
value={(mediaFields[m.id] || {}).title ?? ''}
onChange={(e) =>
setMediaFields((prev) => ({
...prev,
[m.id]: { ...(prev[m.id] || {}), title: e.target.value, context: (prev[m.id] || {}).context || 'ablauf' },
}))
}
/>
<select
className="form-input"
value={(mediaFields[m.id] || {}).context || 'ablauf'}
onChange={(e) =>
setMediaFields((prev) => ({
...prev,
[m.id]: {
...(prev[m.id] || {}),
title: (prev[m.id] || {}).title ?? '',
context: e.target.value,
},
}))
}
>
<option value="ablauf">Ablauf</option>
<option value="detail">Detail</option>
<option value="trainer_hint">Trainer-Hinweis</option>
</select>
<button
type="button"
className="btn btn-primary"
style={{ fontSize: '12px' }}
disabled={mediaSavingId === m.id}
onClick={() => saveMediaMeta(m.id)}
>
{mediaSavingId === m.id ? 'Speichern…' : 'Titel & Sektion speichern'}
</button>
</div>
<button
type="button"
className="btn btn-secondary"
style={{
marginTop: '8px',
fontSize: '12px',
padding: '6px 12px',
}}
onClick={() => handleDeleteMedia(m.id)}
>
Aus Übung entfernen
</button>
</div>
</li>
))}
</div>
</div>
</li>
)
})}
</ul>
)}
<p style={{ color: 'var(--text3)', fontSize: '12px', marginTop: mediaList.length ? '12px' : 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.
</p>
{archiveOpen && (
<div
role="dialog"
@ -1710,16 +1558,6 @@ function ExerciseFormPage() {
onChange={(e) => setArchiveQ(e.target.value)}
style={{ marginBottom: '8px' }}
/>
<select
className="form-input"
value={archiveCtx}
onChange={(e) => setArchiveCtx(e.target.value)}
style={{ marginBottom: '12px' }}
>
<option value="ablauf">Sektion: Ablauf</option>
<option value="detail">Sektion: Detail</option>
<option value="trainer_hint">Sektion: Trainer-Hinweis</option>
</select>
{archiveLoading && <p style={{ fontSize: '13px', color: 'var(--text3)' }}>Laden</p>}
{archiveError && <p style={{ fontSize: '13px', color: 'var(--danger)' }}>{archiveError}</p>}
{!archiveLoading && !archiveError && archiveItems.length === 0 && (

View File

@ -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 (
<div key={exercise.id} className={exerciseCardClassName(exercise, user?.id)}>
<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>
))}
</div>
{summaryHtml ? (
<div
className="exercise-card-summary exercise-card-summary--rich"
dangerouslySetInnerHTML={{ __html: summaryHtml }}
/>
{exercise.summary && String(exercise.summary).trim() ? (
<div className="exercise-card-summary exercise-card-summary--rich">
<ExerciseRichTextBlock
html={exercise.summary}
exerciseId={exercise.id}
media={exercise.media || []}
/>
</div>
) : null}
</div>
</div>

View File

@ -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<HTMLElement>} */
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
}
}

View File

@ -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<number>}
*/
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<number>}
*/
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
}
}

View File

@ -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
}

View File

@ -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
}