Compare commits

..

No commits in common. "c8c40474d1a26e9884e69568e258f4961035059d" and "f035b5bb0b1c0b757d4333481cefc40b5e40aaba" have entirely different histories.

26 changed files with 558 additions and 2315 deletions

View File

@ -186,7 +186,6 @@ Das widerspricht **nicht** dem Papierkorb: wer kein Recht hat, ein Asset **globa
| Datum | Änderung | | Datum | Änderung |
|-------|----------| |-------|----------|
| 2026-05-08 | **0.8.60 §11:** Inline in Übungstexten (`{{exerciseMedia:id}}` / `data-shinkan-exercise-media`); Server-Normalisierung + Validierung; Client-Sanitize und zentraler Block-Renderer (`ExerciseRichTextBlock`); CREATE ohne bestehende `exercise_media` lehnt Platzhalter ab. |
| 2026-05-07 | **0.8.59:** Dokumentation — Aktiver Verein (Profil/Header/`effective_club_id`) für Plattform-Admin und UI-Dropdown synchron; kein fachliches Archiv-Schema-Change. | | 2026-05-07 | **0.8.59:** Dokumentation — Aktiver Verein (Profil/Header/`effective_club_id`) für Plattform-Admin und UI-Dropdown synchron; kein fachliches Archiv-Schema-Change. |
| 2026-05-07 | **0.8.58:** Medien **`official`:** Lifecycle schwerpunktmäßig **Superadmin** (nicht Plattform-Admin); Bearbeitungsdialog Bibliothek für andere Rollen **Lesemodus**; Superadmin-Upload: Vereinskontext folgt aktiv gesetztem Verein / `effective_club_id`. | | 2026-05-07 | **0.8.58:** Medien **`official`:** Lifecycle schwerpunktmäßig **Superadmin** (nicht Plattform-Admin); Bearbeitungsdialog Bibliothek für andere Rollen **Lesemodus**; Superadmin-Upload: Vereinskontext folgt aktiv gesetztem Verein / `effective_club_id`. |
| 2026-05-07 | **0.8.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. | | 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. |
@ -197,19 +196,20 @@ Das widerspricht **nicht** dem Papierkorb: wer kein Recht hat, ein Asset **globa
--- ---
## 11. Inline-Medien im Fließtext (Umsetzung & Leitplanken) ## 11. Inline-Medien im Fließtext (Planung, Leitplanken)
**Status:** Frontend-Renderer und API-Validierung/Normalisierung umgesetzt (App ≥ 0.8.60); verbindliche Leitplanken unten. **Status:** nicht implementiert; verbindlich nur als **Richtschnur**, damit später **kein Big-Bang-Refactor** nötig ist.
### 11.1 Ziel ### 11.1 Ziel
- Medien (Player, Bild) sollen **an definierter Stelle** in Feldern wie Ablauf / Ziel / Notizen erscheinen können zusätzlich oder statt reiner Zuordnung zu den Sektionen `ablauf` / `detail` / `trainer_hint`. - Medien (Player, Bild) sollen **an definierter Stelle** in Feldern wie Ablauf / Ziel / Notizen erscheinen können zusätzlich oder statt reiner Zuordnung zu den Sektionen `ablauf` / `detail` / `trainer_hint`.
- **Keine zweite Sichtbarkeit:** Inline verweist immer auf dieselbe **Übungs-Medium-Zeile** (`exercise_media.id`) bzw. indirekt auf das gleiche Asset wie die Medienliste; **Lesen/Ausliefern** nur nach **bestehender** Übungs- + Medien-Governance (§4.1). - **Keine zweite Sichtbarkeit:** Inline verweist immer auf dieselbe **Übungs-Medium-Zeile** (`exercise_media.id`) bzw. indirekt auf das gleiche Asset wie die Medienliste; **Lesen/Ausliefern** nur nach **bestehender** Übungs- + Medien-Governance (§4.1).
### 11.2 Platzhalter-Konvention (**festgelegt**) ### 11.2 Platzhalter-Konvention (Vorschlag für spätere Umsetzung)
- **Kanonisches Markup nach Speichern:** `<span data-shinkan-exercise-media="<numerische exercise_media.id>" class="shinkan-inline-media"></span>`. - Beim **Speichern** im Rich-Text: markierter Verweis, z.B.
- **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**. `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).
### 11.3 Rendering & Sicherheit ### 11.3 Rendering & Sicherheit
@ -224,8 +224,7 @@ Das widerspricht **nicht** dem Papierkorb: wer kein Recht hat, ein Asset **globa
### 11.5 Wann umsetzen (Reihenfolge) ### 11.5 Wann umsetzen (Reihenfolge)
1. ~~**Erledigt (Basis):** Medien-Archiv, `media_assets`, Upload/Dedupe, Speicherpfad, Papierkorb, Bibliothek `/media`, Verknüpfung `from-asset`, Governance `official`/Copyright.~~ 1. ~~**Erledigt (Basis):** Medien-Archiv, `media_assets`, Upload/Dedupe, Speicherpfad, Papierkorb, Bibliothek `/media`, Verknüpfung `from-asset`, Governance `official`/Copyright.~~
2. **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). 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.
(Optional Feinschliff: komfortablerer Medien-Picker statt Prompt bei mehreren Medien.)
3. **Editor:** kein Zwang zum vollen Block-Editor vorab; **Platzhalter im bestehenden RTE** ist der vorgesehene schlanke Einstieg. 3. **Editor:** kein Zwang zum vollen Block-Editor vorab; **Platzhalter im bestehenden RTE** ist der vorgesehene schlanke Einstieg.
### 11.6 Refactor-Vermeidung (jetzt schon) ### 11.6 Refactor-Vermeidung (jetzt schon)

View File

@ -52,8 +52,6 @@ ENVIRONMENT=production
# Medien (Docker Compose): SHINKAN_MEDIA_HOST = Verzeichnis auf dem Host (Bind-Mount), # Medien (Docker Compose): SHINKAN_MEDIA_HOST = Verzeichnis auf dem Host (Bind-Mount),
# MEDIA_ROOT = gleicher Pfad im Container (muss mit dem Mount-Ziel übereinstimmen — FastAPI). # MEDIA_ROOT = gleicher Pfad im Container (muss mit dem Mount-Ziel übereinstimmen — FastAPI).
# Prod: Verzeichnis VOR erstem "docker compose up" anlegen (z. B. sudo mkdir -p …/prod).
# Wenn Docker meldet "chown … operation not permitted": oft NAS/NFS oder Rechte — lokalen Pfad nutzen.
SHINKAN_MEDIA_HOST=/shinkan-media SHINKAN_MEDIA_HOST=/shinkan-media
MEDIA_ROOT=/app/media MEDIA_ROOT=/app/media

View File

@ -1,101 +0,0 @@
"""
Ü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,13 +29,6 @@ from club_tenancy import (
) )
from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible, library_content_visibility_sql from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible, library_content_visibility_sql
from media_storage import get_effective_media_root, library_storage_key, path_under_media_root from media_storage import get_effective_media_root, library_storage_key, path_under_media_root
from exercise_rich_text import (
RICH_HTML_EXERCISE_FIELDS,
assert_no_inline_media_references_on_create,
collect_inline_exercise_media_ids,
normalize_inline_exercise_media_markup,
validate_inline_exercise_media_ids_for_exercise,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -1819,16 +1812,6 @@ def create_exercise(
if body.visibility == "club" and club_id is None: if body.visibility == "club" and club_id is None:
club_id = tenant.effective_club_id club_id = tenant.effective_club_id
# §11 Inline-Medien: Kurzsyntax → kanonisches Markup; Verweise erst nach Medien-Anlage möglich
create_ids: set[int] = set()
for fld in sorted(RICH_HTML_EXERCISE_FIELDS):
raw_html = getattr(body, fld, None)
if raw_html:
normed = normalize_inline_exercise_media_markup(raw_html)
setattr(body, fld, normed)
create_ids |= collect_inline_exercise_media_ids(normed or "")
assert_no_inline_media_references_on_create(create_ids)
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
assert_valid_governance_visibility( assert_valid_governance_visibility(
@ -1885,25 +1868,16 @@ def update_exercise(
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute( cur.execute(
f"""SELECT created_by, visibility, club_id, "SELECT created_by, visibility, club_id FROM exercises WHERE id = %s",
{", ".join(sorted(RICH_HTML_EXERCISE_FIELDS))}
FROM exercises WHERE id = %s""",
(exercise_id,), (exercise_id,),
) )
row = cur.fetchone() row = cur.fetchone()
if not row: if not row:
raise HTTPException(status_code=404, detail="Übung nicht gefunden") raise HTTPException(status_code=404, detail="Übung nicht gefunden")
rd_full = r2d(row)
rich_row = {fld: rd_full.get(fld) for fld in RICH_HTML_EXERCISE_FIELDS}
_assert_can_edit_exercise(cur, exercise_id, tenant) _assert_can_edit_exercise(cur, exercise_id, tenant)
rd = { rd = r2d(row)
"created_by": rd_full.get("created_by"),
"visibility": rd_full.get("visibility"),
"club_id": rd_full.get("club_id"),
}
ex_vis = (rd.get("visibility") or "private").strip().lower() ex_vis = (rd.get("visibility") or "private").strip().lower()
ex_cid = rd.get("club_id") ex_cid = rd.get("club_id")
if ex_cid is not None: if ex_cid is not None:
@ -1914,23 +1888,6 @@ def update_exercise(
promote_media_flag = raw_promo is True promote_media_flag = raw_promo is True
default_official_copy = data.pop("default_official_media_copyright", None) default_official_copy = data.pop("default_official_media_copyright", None)
merged_rich = {fld: rich_row.get(fld) for fld in RICH_HTML_EXERCISE_FIELDS}
for fld in RICH_HTML_EXERCISE_FIELDS:
if fld not in data:
continue
raw_v = data[fld]
if raw_v is None:
merged_rich[fld] = None
continue
if isinstance(raw_v, str):
nv = normalize_inline_exercise_media_markup(raw_v)
data[fld] = nv
merged_rich[fld] = nv
inline_union: set[int] = set()
for val in merged_rich.values():
inline_union |= collect_inline_exercise_media_ids(val if isinstance(val, str) else None)
validate_inline_exercise_media_ids_for_exercise(cur, exercise_id, inline_union)
next_vis = ex_vis next_vis = ex_vis
if "visibility" in data and data["visibility"] is not None: if "visibility" in data and data["visibility"] is not None:
v_raw = str(data["visibility"]).strip().lower() v_raw = str(data["visibility"]).strip().lower()
@ -2107,11 +2064,7 @@ def create_exercise_variant(
seq = cur.fetchone()["n"] seq = cur.fetchone()["n"]
desc = (body.description or "").strip() or None desc = (body.description or "").strip() or None
exec_ch_raw = (body.execution_changes or "").strip() or None exec_ch = (body.execution_changes or "").strip() or None
exec_ch = normalize_inline_exercise_media_markup(exec_ch_raw) if exec_ch_raw else None
if exec_ch:
v_ids = collect_inline_exercise_media_ids(exec_ch)
validate_inline_exercise_media_ids_for_exercise(cur, exercise_id, v_ids)
diff = (body.difficulty_adjustment or "").strip() or None diff = (body.difficulty_adjustment or "").strip() or None
cur.execute( cur.execute(
@ -2165,13 +2118,7 @@ def update_exercise_variant(
if "description" in data: if "description" in data:
old["description"] = (data["description"] or "").strip() or None old["description"] = (data["description"] or "").strip() or None
if "execution_changes" in data: if "execution_changes" in data:
ec_raw = (data["execution_changes"] or "").strip() or None old["execution_changes"] = (data["execution_changes"] or "").strip() or None
ec_norm = normalize_inline_exercise_media_markup(ec_raw) if ec_raw else None
if ec_norm:
validate_inline_exercise_media_ids_for_exercise(
cur, exercise_id, collect_inline_exercise_media_ids(ec_norm)
)
old["execution_changes"] = ec_norm
if "duration_min" in data: if "duration_min" in data:
old["duration_min"] = data["duration_min"] old["duration_min"] = data["duration_min"]
if "duration_max" in data: if "duration_max" in data:

View File

@ -1,51 +0,0 @@
"""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

@ -1,44 +0,0 @@
"""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 # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.64" APP_VERSION = "0.8.59"
BUILD_DATE = "2026-05-08" BUILD_DATE = "2026-05-07"
DB_SCHEMA_VERSION = "20260508049" DB_SCHEMA_VERSION = "20260508049"
MODULE_VERSIONS = { MODULE_VERSIONS = {
@ -17,7 +17,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0", "groups": "0.1.0",
"skills": "0.1.0", "skills": "0.1.0",
"methods": "0.1.0", "methods": "0.1.0",
"exercises": "2.19.0", # Inline-Medien §11: Fließtext-Platzhalter exercise_media.id, Normalisierung/Validierung; CREATE ohne Platzhalter "exercises": "2.18.0", # Vereins-Übung: Copyright-Pflicht File-Assets; official nur Superadmin (Governance)
"training_units": "0.2.0", "training_units": "0.2.0",
"training_programs": "0.1.0", "training_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile "planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
@ -29,42 +29,6 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ 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", "version": "0.8.59",
"date": "2026-05-07", "date": "2026-05-07",

View File

@ -54,8 +54,6 @@ services:
MEDIAWIKI_CATEGORY_MODELS: "${MEDIAWIKI_CATEGORY_MODELS:-Reifegradmodelle}" MEDIAWIKI_CATEGORY_MODELS: "${MEDIAWIKI_CATEGORY_MODELS:-Reifegradmodelle}"
# Medien: Host-Pfad SHINKAN_MEDIA_HOST (in .env), Ziel im Container MEDIA_ROOT. # Medien: Host-Pfad SHINKAN_MEDIA_HOST (in .env), Ziel im Container MEDIA_ROOT.
MEDIA_ROOT: "${MEDIA_ROOT:-/app/media}" MEDIA_ROOT: "${MEDIA_ROOT:-/app/media}"
# Bind-Mount: Verzeichnis muss auf dem Host existieren und chown für den Docker-Daemon
# zulassen (lokale Platte). Bei NFS/SMB oft "chown … operation not permitted" → anderen Pfad.
volumes: volumes:
- ${SHINKAN_MEDIA_HOST:-/shinkan-media}:${MEDIA_ROOT:-/app/media} - ${SHINKAN_MEDIA_HOST:-/shinkan-media}:${MEDIA_ROOT:-/app/media}
ports: ports:

View File

@ -3895,30 +3895,7 @@ a.analysis-split__nav-item {
overflow-y: auto; overflow-y: auto;
resize: vertical; 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 ul,
.rich-text-editor ol { .rich-text-editor ol {
margin: 0.35rem 0; margin: 0.35rem 0;
@ -3944,252 +3921,6 @@ a.analysis-split__nav-item {
pointer-events: none; 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 { .rich-text-content {
font-size: 16px; font-size: 16px;
line-height: 1.55; line-height: 1.55;

View File

@ -1,123 +0,0 @@
/**
* 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,7 +3,52 @@
*/ */
import React from 'react' import React from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import ExerciseRichTextBlock from './ExerciseRichTextBlock' 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>
)
}
function TagRow({ exercise }) { function TagRow({ exercise }) {
const tags = [] const tags = []
@ -67,8 +112,11 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
} }
if (!exercise) return null if (!exercise) return null
const resolvedId = exercise.id ?? exerciseId
const meta = metaParts(exercise) const meta = metaParts(exercise)
const visibleMedia = (exercise.media || []).filter((m) => {
const lc = String(m.asset_lifecycle_state || 'active').toLowerCase()
return lc !== 'trash_hidden'
})
return ( return (
<div className="exercise-coach-catalog" style={{ fontSize: '0.93rem', lineHeight: 1.5 }}> <div className="exercise-coach-catalog" style={{ fontSize: '0.93rem', lineHeight: 1.5 }}>
@ -84,13 +132,13 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px', letterSpacing: '0.04em' }}> <h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px', letterSpacing: '0.04em' }}>
Kurzbeschreibung Kurzbeschreibung
</h3> </h3>
<ExerciseRichTextBlock html={exercise.summary} exerciseId={resolvedId} media={exercise.media} /> <HtmlBlock html={exercise.summary} />
</section> </section>
)} )}
{exercise.goal && ( {exercise.goal && (
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}> <section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>Ziel</h3> <h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>Ziel</h3>
<ExerciseRichTextBlock html={exercise.goal} exerciseId={resolvedId} media={exercise.media} /> <HtmlBlock html={exercise.goal} />
</section> </section>
)} )}
{(exercise.equipment || []).length > 0 && ( {(exercise.equipment || []).length > 0 && (
@ -108,13 +156,32 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
{exercise.preparation && ( {exercise.preparation && (
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}> <section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>Vorbereitung</h3> <h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>Vorbereitung</h3>
<ExerciseRichTextBlock html={exercise.preparation} exerciseId={resolvedId} media={exercise.media} /> <HtmlBlock html={exercise.preparation} />
</section> </section>
)} )}
{exercise.execution && ( {exercise.execution && (
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}> <section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>Ablauf</h3> <h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>Ablauf</h3>
<ExerciseRichTextBlock html={exercise.execution} exerciseId={resolvedId} media={exercise.media} /> <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>
))}
</section> </section>
)} )}
{exercise.trainer_notes && ( {exercise.trainer_notes && (
@ -122,7 +189,7 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}> <h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>
Hinweise Trainer (Katalog) Hinweise Trainer (Katalog)
</h3> </h3>
<ExerciseRichTextBlock html={exercise.trainer_notes} exerciseId={resolvedId} media={exercise.media} /> <HtmlBlock html={exercise.trainer_notes} />
</section> </section>
)} )}
{exerciseId != null && ( {exerciseId != null && (

View File

@ -1,132 +0,0 @@
/**
* 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

@ -1,396 +0,0 @@
/**
* 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

@ -1,71 +0,0 @@
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

@ -1,68 +0,0 @@
/**
* 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,7 +4,15 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import ExerciseRichTextBlock from './ExerciseRichTextBlock' 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 }} />
)
}
function TagMini({ exercise }) { function TagMini({ exercise }) {
const parts = [] const parts = []
@ -121,7 +129,7 @@ export default function ExercisePeekModal({
</div> </div>
{variant.description ? ( {variant.description ? (
<div style={{ marginTop: 8, fontSize: '0.9rem', color: 'var(--text2)' }}> <div style={{ marginTop: 8, fontSize: '0.9rem', color: 'var(--text2)' }}>
<ExerciseRichTextBlock html={variant.description} exerciseId={exercise.id} media={exercise.media} /> <HtmlBlock html={variant.description} />
</div> </div>
) : null} ) : null}
{variant.execution_changes ? ( {variant.execution_changes ? (
@ -129,14 +137,14 @@ export default function ExercisePeekModal({
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', marginBottom: 6 }}> <h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', marginBottom: 6 }}>
Durchführung (Variante) Durchführung (Variante)
</h4> </h4>
<ExerciseRichTextBlock html={variant.execution_changes} exerciseId={exercise.id} media={exercise.media} /> <HtmlBlock html={variant.execution_changes} />
</div> </div>
) : null} ) : null}
</div> </div>
) : null} ) : null}
{exercise.summary && ( {exercise.summary && (
<div style={{ fontSize: '0.95rem', color: 'var(--text2)' }}> <div style={{ fontSize: '0.95rem', color: 'var(--text2)' }}>
<ExerciseRichTextBlock html={exercise.summary} exerciseId={exercise.id} media={exercise.media} /> <HtmlBlock html={exercise.summary} />
</div> </div>
)} )}
<TagMini exercise={exercise} /> <TagMini exercise={exercise} />
@ -146,25 +154,25 @@ export default function ExercisePeekModal({
{exercise.goal && ( {exercise.goal && (
<> <>
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', marginBottom: 6 }}>Ziel</h4> <h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', marginBottom: 6 }}>Ziel</h4>
<ExerciseRichTextBlock html={exercise.goal} exerciseId={exercise.id} media={exercise.media} /> <HtmlBlock html={exercise.goal} />
</> </>
)} )}
{exercise.preparation && ( {exercise.preparation && (
<> <>
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', margin: '14px 0 6px' }}>Vorbereitung</h4> <h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', margin: '14px 0 6px' }}>Vorbereitung</h4>
<ExerciseRichTextBlock html={exercise.preparation} exerciseId={exercise.id} media={exercise.media} /> <HtmlBlock html={exercise.preparation} />
</> </>
)} )}
{exercise.execution && ( {exercise.execution && (
<> <>
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', margin: '14px 0 6px' }}>Ablauf</h4> <h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', margin: '14px 0 6px' }}>Ablauf</h4>
<ExerciseRichTextBlock html={exercise.execution} exerciseId={exercise.id} media={exercise.media} /> <HtmlBlock html={exercise.execution} />
</> </>
)} )}
{exercise.trainer_notes && ( {exercise.trainer_notes && (
<> <>
<h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', margin: '14px 0 6px' }}>Trainer-Hinweise</h4> <h4 style={{ fontSize: '0.85rem', color: 'var(--text3)', margin: '14px 0 6px' }}>Trainer-Hinweise</h4>
<ExerciseRichTextBlock html={exercise.trainer_notes} exerciseId={exercise.id} media={exercise.media} /> <HtmlBlock html={exercise.trainer_notes} />
</> </>
)} )}
</> </>

View File

@ -1,107 +0,0 @@
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,12 +1,4 @@
import React, { useRef, useEffect, useState, useCallback } from 'react' 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) { function exec(cmd, value = null) {
try { try {
@ -53,140 +45,12 @@ function normalText() {
formatBlock('p') 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). * Leichter WYSIWYG (contenteditable). Wert kommt von außen zuverlässig ins DOM (Edit-Modus).
* @param {{
* linkedExerciseMedia?: object[],
* }} [extra]
*/ */
export default function RichTextEditor({ export default function RichTextEditor({ value, onChange, placeholder, minHeight = '140px' }) {
value,
onChange,
placeholder,
minHeight = '140px',
inlineExerciseId = null,
linkedExerciseMedia = [],
onExerciseMediaListChanged,
}) {
const ref = useRef(null) const ref = useRef(null)
const pendingRangeRef = useRef(null)
const [focused, setFocused] = useState(false) const [focused, setFocused] = useState(false)
const [fileModalOpen, setFileModalOpen] = useState(false)
const [embedModalOpen, setEmbedModalOpen] = useState(false)
const showInlineToolbar = inlineExerciseId != null && Number(inlineExerciseId) > 0
useEffect(() => { useEffect(() => {
const el = ref.current const el = ref.current
@ -202,129 +66,6 @@ export default function RichTextEditor({
onChange(ref.current.innerHTML) onChange(ref.current.innerHTML)
}, [onChange]) }, [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) => { const run = (fn) => (e) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
@ -392,26 +133,6 @@ export default function RichTextEditor({
<button type="button" className="rte-btn" title="Link einfügen" onMouseDown={onLink}> <button type="button" className="rte-btn" title="Link einfügen" onMouseDown={onLink}>
Link Link
</button> </button>
{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 <button
type="button" type="button"
className="rte-btn" className="rte-btn"
@ -437,31 +158,7 @@ export default function RichTextEditor({
sync() sync()
}} }}
onInput={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> </div>
) )
} }

View File

@ -1,14 +0,0 @@
/** 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,10 +1,57 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { Link, useNavigate, useParams, useLocation } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock' import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaStrip' import { sanitizeTrainerHtml } from '../utils/htmlUtils'
import { formatSkillLevelSlug } from '../constants/skillLevels' import { formatSkillLevelSlug } from '../constants/skillLevels'
function HtmlBlock({ html, className = '' }) {
if (!html || !String(html).trim()) return null
const safe = sanitizeTrainerHtml(html)
return (
<div
className={`rich-text-content ${className}`}
dangerouslySetInnerHTML={{ __html: safe }}
/>
)
}
function MediaBlock({ media, exerciseId }) {
if (media.embed_url) {
return (
<div style={{ marginTop: '0.5rem' }}>
<a href={media.embed_url} target="_blank" rel="noreferrer">
{media.embed_url}
</a>
{media.embed_platform && (
<span style={{ color: 'var(--text2)', marginLeft: '0.5rem', fontSize: '0.8rem' }}>
({media.embed_platform})
</span>
)}
</div>
)
}
const src = resolveExerciseMediaFileUrl(exerciseId, media)
if (!src) return null
if (media.media_type === 'image' || (media.mime_type && media.mime_type.startsWith('image/'))) {
return (
<img
src={src}
alt={media.title || media.original_filename || ''}
style={{ maxWidth: '100%', borderRadius: '8px', marginTop: '0.5rem' }}
/>
)
}
if (media.media_type === 'video' || (media.mime_type && media.mime_type.startsWith('video/'))) {
return <video src={src} controls style={{ width: '100%', marginTop: '0.5rem', borderRadius: '8px' }} />
}
return (
<a href={src} target="_blank" rel="noreferrer" style={{ display: 'inline-block', marginTop: '0.5rem' }}>
{media.title || media.original_filename || 'Datei öffnen'}
</a>
)
}
function TagRow({ exercise }) { function TagRow({ exercise }) {
const tags = [] const tags = []
;(exercise.focus_areas || []).forEach((f) => { ;(exercise.focus_areas || []).forEach((f) => {
@ -53,7 +100,6 @@ function metaParts(exercise) {
function ExerciseDetailPage() { function ExerciseDetailPage() {
const { id } = useParams() const { id } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const [exercise, setExercise] = useState(null) const [exercise, setExercise] = useState(null)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -105,26 +151,27 @@ function ExerciseDetailPage() {
if (!exercise) return null if (!exercise) return null
const meta = metaParts(exercise) const meta = metaParts(exercise)
const fromExerciseEdit = location.state?.fromExerciseEdit === true const visibleMedia = (exercise.media || []).filter((m) => {
const lc = String(m.asset_lifecycle_state || 'active').toLowerCase()
return lc !== 'trash_hidden'
})
return ( return (
<div className="exercise-detail-shell" style={{ padding: '12px 12px 24px' }}> <div className="exercise-detail-shell" style={{ padding: '12px 12px 24px' }}>
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}> <div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap' }}>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}> <button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}>
Übersicht Übersicht
</button> </button>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginLeft: 'auto' }}>
<Link to={`/exercises/${exercise.id}/edit`} className="btn btn-primary"> <Link to={`/exercises/${exercise.id}/edit`} className="btn btn-primary">
{fromExerciseEdit ? 'Zurück zur Bearbeitung' : 'Bearbeiten'} Bearbeiten
</Link> </Link>
</div> </div>
</div>
<div className="card exercise-detail-section"> <div className="card exercise-detail-section">
<h1 style={{ margin: 0, fontSize: '1.35rem', lineHeight: 1.25 }}>{exercise.title}</h1> <h1 style={{ margin: 0, fontSize: '1.35rem', lineHeight: 1.25 }}>{exercise.title}</h1>
{exercise.summary && ( {exercise.summary && (
<div style={{ marginTop: '10px', color: 'var(--text2)', fontSize: '15px' }}> <div style={{ marginTop: '10px', color: 'var(--text2)', fontSize: '15px' }}>
<ExerciseRichTextBlock html={exercise.summary} exerciseId={exercise.id} media={exercise.media} /> <HtmlBlock html={exercise.summary} />
</div> </div>
)} )}
<TagRow exercise={exercise} /> <TagRow exercise={exercise} />
@ -139,7 +186,7 @@ function ExerciseDetailPage() {
{exercise.goal && ( {exercise.goal && (
<section className="card exercise-detail-section"> <section className="card exercise-detail-section">
<h2>Ziel</h2> <h2>Ziel</h2>
<ExerciseRichTextBlock html={exercise.goal} exerciseId={exercise.id} media={exercise.media} /> <HtmlBlock html={exercise.goal} />
</section> </section>
)} )}
@ -157,23 +204,39 @@ function ExerciseDetailPage() {
{exercise.preparation && ( {exercise.preparation && (
<section className="card exercise-detail-section"> <section className="card exercise-detail-section">
<h2>Vorbereitung</h2> <h2>Vorbereitung</h2>
<ExerciseRichTextBlock html={exercise.preparation} exerciseId={exercise.id} media={exercise.media} /> <HtmlBlock html={exercise.preparation} />
</section> </section>
)} )}
{exercise.execution && ( {exercise.execution && (
<section className="card exercise-detail-section"> <section className="card exercise-detail-section">
<h2>Ablauf</h2> <h2>Ablauf</h2>
<ExerciseRichTextBlock html={exercise.execution} exerciseId={exercise.id} media={exercise.media} /> <HtmlBlock html={exercise.execution} />
</section> </section>
)} )}
<ExerciseAttachmentMediaStrip exercise={exercise} exerciseId={exercise.id} /> {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>
)}
{exercise.trainer_notes && ( {exercise.trainer_notes && (
<section className="card exercise-detail-section"> <section className="card exercise-detail-section">
<h2>Hinweise für Trainer</h2> <h2>Hinweise für Trainer</h2>
<ExerciseRichTextBlock html={exercise.trainer_notes} exerciseId={exercise.id} media={exercise.media} /> <HtmlBlock html={exercise.trainer_notes} />
</section> </section>
)} )}
@ -244,11 +307,7 @@ function ExerciseDetailPage() {
{v.description && <p style={{ color: 'var(--text2)', marginTop: '4px' }}>{v.description}</p>} {v.description && <p style={{ color: 'var(--text2)', marginTop: '4px' }}>{v.description}</p>}
{v.execution_changes && ( {v.execution_changes && (
<div style={{ marginTop: '8px' }}> <div style={{ marginTop: '8px' }}>
<ExerciseRichTextBlock <HtmlBlock html={v.execution_changes} />
html={v.execution_changes}
exerciseId={exercise.id}
media={exercise.media}
/>
</div> </div>
)} )}
</div> </div>

View File

@ -4,15 +4,87 @@ import api, { buildExerciseApiPayload } from '../utils/api'
import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl' import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
import RichTextEditor from '../components/RichTextEditor' import RichTextEditor from '../components/RichTextEditor'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel' 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 { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
import { useAuth } from '../context/AuthContext' 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 = [ const INTENSITY_OPTIONS = [
{ value: '', label: '—' }, { value: '', label: '—' },
{ value: 'niedrig', label: 'niedrig' }, { value: 'niedrig', label: 'niedrig' },
@ -97,15 +169,7 @@ function buildVariantPayloadFromRow(row) {
} }
/** Gemeinsame Felder für „Variante bearbeiten“ und „Neue Variante“. */ /** Gemeinsame Felder für „Variante bearbeiten“ und „Neue Variante“. */
function ExerciseVariantFields({ function ExerciseVariantFields({ row, onPatch, prerequisiteOthers, rteMinHeight = '110px' }) {
row,
onPatch,
prerequisiteOthers,
rteMinHeight = '110px',
inlineExerciseId,
linkedExerciseMedia = [],
onExerciseMediaListChanged,
}) {
return ( return (
<> <>
<div className="form-row"> <div className="form-row">
@ -134,9 +198,6 @@ function ExerciseVariantFields({
onChange={(html) => onPatch({ execution_changes: html })} onChange={(html) => onPatch({ execution_changes: html })}
placeholder="Was unterscheidet diese Variante? (Listen über Symbolleiste)" placeholder="Was unterscheidet diese Variante? (Listen über Symbolleiste)"
minHeight={rteMinHeight} minHeight={rteMinHeight}
inlineExerciseId={inlineExerciseId}
linkedExerciseMedia={linkedExerciseMedia}
onExerciseMediaListChanged={onExerciseMediaListChanged}
/> />
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
@ -374,7 +435,6 @@ function ExerciseFormPage() {
const [mediaList, setMediaList] = useState([]) const [mediaList, setMediaList] = useState([])
const [loading, setLoading] = useState(!!isEdit) const [loading, setLoading] = useState(!!isEdit)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [formDirty, setFormDirty] = useState(false)
const [skillPick, setSkillPick] = useState('') const [skillPick, setSkillPick] = useState('')
const [variants, setVariants] = useState([]) const [variants, setVariants] = useState([])
const [variantDraft, setVariantDraft] = useState(() => emptyVariantDraft()) const [variantDraft, setVariantDraft] = useState(() => emptyVariantDraft())
@ -383,10 +443,17 @@ function ExerciseFormPage() {
const [variantEditSelection, setVariantEditSelection] = useState(null) const [variantEditSelection, setVariantEditSelection] = useState(null)
const variantsDetailsRef = useRef(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 [mediaFields, setMediaFields] = useState({})
const [mediaSavingId, setMediaSavingId] = useState(null) const [mediaSavingId, setMediaSavingId] = useState(null)
const [archiveOpen, setArchiveOpen] = useState(false) const [archiveOpen, setArchiveOpen] = useState(false)
const [archiveQ, setArchiveQ] = useState('') const [archiveQ, setArchiveQ] = useState('')
const [archiveCtx, setArchiveCtx] = useState('ablauf')
const [archiveLoading, setArchiveLoading] = useState(false) const [archiveLoading, setArchiveLoading] = useState(false)
const [archiveItems, setArchiveItems] = useState([]) const [archiveItems, setArchiveItems] = useState([])
const [archiveError, setArchiveError] = useState(null) const [archiveError, setArchiveError] = useState(null)
@ -397,32 +464,12 @@ function ExerciseFormPage() {
for (const m of mediaList) { for (const m of mediaList) {
next[m.id] = { next[m.id] = {
title: m.title || '', title: m.title || '',
context: m.context || 'ablauf',
} }
} }
setMediaFields(next) setMediaFields(next)
}, [mediaList]) }, [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(() => { useEffect(() => {
if (!archiveOpen) return undefined if (!archiveOpen) return undefined
let cancelled = false let cancelled = false
@ -487,7 +534,6 @@ function ExerciseFormPage() {
setVariants([]) setVariants([])
setVariantDraft(emptyVariantDraft()) setVariantDraft(emptyVariantDraft())
setVariantEditSelection(null) setVariantEditSelection(null)
setFormDirty(false)
setLoading(false) setLoading(false)
return return
} }
@ -502,7 +548,6 @@ function ExerciseFormPage() {
setVariants((exercise.variants || []).map(apiVariantToRow)) setVariants((exercise.variants || []).map(apiVariantToRow))
setVariantDraft(emptyVariantDraft()) setVariantDraft(emptyVariantDraft())
setVariantEditSelection(null) setVariantEditSelection(null)
setFormDirty(false)
} catch (err) { } catch (err) {
if (!cancelled) { if (!cancelled) {
alert(err.message || 'Übung nicht ladbar') alert(err.message || 'Übung nicht ladbar')
@ -532,7 +577,6 @@ function ExerciseFormPage() {
}, [variantEditSelection]) }, [variantEditSelection])
const updateFormField = (field, value) => { const updateFormField = (field, value) => {
setFormDirty(true)
setFormData((prev) => ({ ...prev, [field]: value })) setFormData((prev) => ({ ...prev, [field]: value }))
} }
@ -670,7 +714,6 @@ function ExerciseFormPage() {
const ex = await api.getExercise(exerciseId) const ex = await api.getExercise(exerciseId)
setMediaList(ex.media || []) setMediaList(ex.media || [])
setVariants((ex.variants || []).map(apiVariantToRow)) setVariants((ex.variants || []).map(apiVariantToRow))
setFormDirty(false)
alert('Gespeichert.') alert('Gespeichert.')
} else { } else {
const created = await api.createExercise(payload) const created = await api.createExercise(payload)
@ -694,7 +737,7 @@ function ExerciseFormPage() {
try { try {
await api.attachExerciseMediaFromAsset(exerciseId, { await api.attachExerciseMediaFromAsset(exerciseId, {
media_asset_id: assetId, media_asset_id: assetId,
context: 'ablauf', context: archiveCtx,
title: '', title: '',
description: '', description: '',
is_primary: false, is_primary: false,
@ -711,11 +754,92 @@ function ExerciseFormPage() {
[mediaList], [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) => { const handleDeleteMedia = async (mid) => {
if ( if (
!confirm( !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.\n\n' + 'Dieses Medium aus der Übung entfernen? Nur die Verknüpfung wird gelöscht. Die Datei bleibt im Archiv, solange sie noch woanders genutzt wird.',
'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 return
@ -766,6 +890,7 @@ function ExerciseFormPage() {
try { try {
await api.updateExerciseMedia(exerciseId, mid, { await api.updateExerciseMedia(exerciseId, mid, {
title: fld.title.trim() || null, title: fld.title.trim() || null,
context: fld.context,
}) })
await refreshMedia() await refreshMedia()
} catch (e) { } catch (e) {
@ -782,7 +907,6 @@ function ExerciseFormPage() {
} }
const updateVariantField = (id, patch) => { const updateVariantField = (id, patch) => {
setFormDirty(true)
setVariants((prev) => prev.map((v) => (v.id === id ? { ...v, ...patch } : v))) setVariants((prev) => prev.map((v) => (v.id === id ? { ...v, ...patch } : v)))
} }
@ -886,18 +1010,7 @@ function ExerciseFormPage() {
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
style={{ marginLeft: '8px' }} style={{ marginLeft: '8px' }}
onClick={() => { onClick={() => navigate(`/exercises/${exerciseId}`)}
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 Ansehen
</button> </button>
@ -927,9 +1040,6 @@ function ExerciseFormPage() {
onChange={(html) => updateFormField('summary', html)} onChange={(html) => updateFormField('summary', html)}
placeholder="Kurzbeschreibung (optional)" placeholder="Kurzbeschreibung (optional)"
minHeight="80px" minHeight="80px"
inlineExerciseId={isEdit ? exerciseId : null}
linkedExerciseMedia={isEdit ? mediaList : []}
onExerciseMediaListChanged={refreshMedia}
/> />
</div> </div>
@ -940,9 +1050,6 @@ function ExerciseFormPage() {
onChange={(html) => updateFormField('goal', html)} onChange={(html) => updateFormField('goal', html)}
placeholder="Trainingsziel" placeholder="Trainingsziel"
minHeight="120px" minHeight="120px"
inlineExerciseId={isEdit ? exerciseId : null}
linkedExerciseMedia={isEdit ? mediaList : []}
onExerciseMediaListChanged={refreshMedia}
/> />
</div> </div>
@ -953,9 +1060,6 @@ function ExerciseFormPage() {
onChange={(html) => updateFormField('execution', html)} onChange={(html) => updateFormField('execution', html)}
placeholder="Ablauf Schritt für Schritt" placeholder="Ablauf Schritt für Schritt"
minHeight="180px" minHeight="180px"
inlineExerciseId={isEdit ? exerciseId : null}
linkedExerciseMedia={isEdit ? mediaList : []}
onExerciseMediaListChanged={refreshMedia}
/> />
</div> </div>
@ -966,9 +1070,6 @@ function ExerciseFormPage() {
onChange={(html) => updateFormField('preparation', html)} onChange={(html) => updateFormField('preparation', html)}
placeholder="Matten, Raum, …" placeholder="Matten, Raum, …"
minHeight="100px" minHeight="100px"
inlineExerciseId={isEdit ? exerciseId : null}
linkedExerciseMedia={isEdit ? mediaList : []}
onExerciseMediaListChanged={refreshMedia}
/> />
</div> </div>
@ -979,9 +1080,6 @@ function ExerciseFormPage() {
onChange={(html) => updateFormField('trainer_notes', html)} onChange={(html) => updateFormField('trainer_notes', html)}
placeholder="Sicherheit, Varianten-Hinweise, …" placeholder="Sicherheit, Varianten-Hinweise, …"
minHeight="100px" minHeight="100px"
inlineExerciseId={isEdit ? exerciseId : null}
linkedExerciseMedia={isEdit ? mediaList : []}
onExerciseMediaListChanged={refreshMedia}
/> />
</div> </div>
@ -1273,15 +1371,9 @@ function ExerciseFormPage() {
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Neue Variante</h3> <h3 style={{ marginTop: 0, fontSize: '1rem' }}>Neue Variante</h3>
<ExerciseVariantFields <ExerciseVariantFields
row={variantDraft} row={variantDraft}
onPatch={(patch) => { onPatch={(patch) => setVariantDraft((d) => ({ ...d, ...patch }))}
setFormDirty(true)
setVariantDraft((d) => ({ ...d, ...patch }))
}}
prerequisiteOthers={variants} prerequisiteOthers={variants}
rteMinHeight="110px" rteMinHeight="110px"
inlineExerciseId={isEdit ? exerciseId : null}
linkedExerciseMedia={isEdit ? mediaList : []}
onExerciseMediaListChanged={refreshMedia}
/> />
<button type="submit" className="btn btn-primary" style={{ marginTop: '10px' }} disabled={variantBusy}> <button type="submit" className="btn btn-primary" style={{ marginTop: '10px' }} disabled={variantBusy}>
{variantBusy ? 'Anlegen…' : 'Variante anlegen'} {variantBusy ? 'Anlegen…' : 'Variante anlegen'}
@ -1348,9 +1440,6 @@ function ExerciseFormPage() {
onPatch={(patch) => updateVariantField(selectedVariantForEdit.id, patch)} onPatch={(patch) => updateVariantField(selectedVariantForEdit.id, patch)}
prerequisiteOthers={variants.filter((o) => o.id !== selectedVariantForEdit.id)} prerequisiteOthers={variants.filter((o) => o.id !== selectedVariantForEdit.id)}
rteMinHeight="110px" rteMinHeight="110px"
inlineExerciseId={isEdit ? exerciseId : null}
linkedExerciseMedia={isEdit ? mediaList : []}
onExerciseMediaListChanged={refreshMedia}
/> />
</div> </div>
)} )}
@ -1379,13 +1468,8 @@ function ExerciseFormPage() {
{isEdit && ( {isEdit && (
<div className="card" style={{ marginTop: '16px' }}> <div className="card" style={{ marginTop: '16px' }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Medien</h2> <h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Medien</h2>
<p style={{ color: 'var(--text2)', fontSize: '13px', marginBottom: '6px' }}> <p style={{ color: 'var(--text2)', fontSize: '13px' }}>
Neue Uploads oder Embeds über die Textfeld-Symbolleiste (Medien im Text / Embed im Text). Hier Datei oder Embed (YouTube, Vimeo, Instagram, TikTok). Max. 10 pro Übung.
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> </p>
<div <div
style={{ style={{
@ -1403,79 +1487,102 @@ function ExerciseFormPage() {
Medienbibliothek Medienbibliothek
</Link> </Link>
</div> </div>
{mediaList.length > 0 && ( <div style={{ display: 'grid', gap: '12px', marginTop: '12px' }}>
<ul className="exercise-edit-media-strip"> <div>
{mediaList.map((m, idx) => { <label className="form-label">Dateien</label>
const cap = <input
(m.title || '').trim() || type="file"
(m.original_filename || '').trim() || multiple
(m.embed_url ? String(m.embed_url).replace(/^https?:\/\//i, '').slice(0, 80) : '') accept="image/*,video/*,application/pdf"
const sub = [m.media_type, m.embed_platform].filter(Boolean).join(' · ') || 'Medium' onChange={(e) => {
const payloadCaption = ( setMediaFiles(Array.from(e.target.files || []))
[m.title, m.original_filename].find((x) => typeof x === 'string' && x.trim()) || '' e.target.value = ''
).trim() }}
return ( />
<li key={m.id} className="exercise-edit-media-strip__item"> {mediaFiles.length > 0 && (
<div className="exercise-edit-media-strip__lead"> <div style={{ fontSize: '0.875rem', color: 'var(--text2)', marginTop: '6px' }}>
{!m.embed_url ? ( {mediaFiles.length} Datei(en): {mediaFiles.map((f) => f.name).join(', ')}
<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>
)} )}
<div <div className="form-row" style={{ marginTop: '8px' }}>
className="exercise-edit-media-strip__handle" <select className="form-input" value={mediaType} onChange={(e) => setMediaType(e.target.value)}>
title="Mit Drag und Drop in ein Textfeld ziehen" <option value="image">Typ-Fallback: Bild</option>
draggable <option value="video">Typ-Fallback: Video</option>
onDragStart={(e) => { <option value="document">Typ-Fallback: PDF</option>
try { </select>
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 <input
type="text" type="text"
className="form-input exercise-edit-media-strip__title" className="form-input"
placeholder="Titel (wird in der Vorschau und im Platzhalter genutzt)" placeholder="Titel (optional)"
value={(mediaFields[m.id] || {}).title ?? ''} value={mediaTitle}
onChange={(e) => onChange={(e) => setMediaTitle(e.target.value)}
setMediaFields((prev) => ({ style={{ marginTop: '8px' }}
...prev,
[m.id]: {
title: e.target.value,
},
}))
}
/> />
<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> </div>
<div className="exercise-edit-media-strip__actions"> <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 && ( {mediaList.length > 1 && (
<> <>
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }} style={{ fontSize: '11px', padding: '2px 8px' }}
disabled={idx === 0} disabled={idx === 0}
onClick={() => moveMediaRow(idx, -1)} onClick={() => moveMediaRow(idx, -1)}
title="Nach oben" title="Nach oben"
@ -1485,7 +1592,7 @@ function ExerciseFormPage() {
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }} style={{ fontSize: '11px', padding: '2px 8px' }}
disabled={idx >= mediaList.length - 1} disabled={idx >= mediaList.length - 1}
onClick={() => moveMediaRow(idx, 1)} onClick={() => moveMediaRow(idx, 1)}
title="Nach unten" title="Nach unten"
@ -1494,34 +1601,79 @@ function ExerciseFormPage() {
</button> </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 <button
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
style={{ fontSize: '12px', padding: '4px 10px' }} style={{ fontSize: '12px' }}
disabled={mediaSavingId === m.id} disabled={mediaSavingId === m.id}
onClick={() => saveMediaMeta(m.id)} onClick={() => saveMediaMeta(m.id)}
> >
{mediaSavingId === m.id ? '…' : 'Speichern'} {mediaSavingId === m.id ? 'Speichern…' : 'Titel & Sektion speichern'}
</button> </button>
</div>
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
style={{ fontSize: '12px', padding: '4px 10px' }} style={{
marginTop: '8px',
fontSize: '12px',
padding: '6px 12px',
}}
onClick={() => handleDeleteMedia(m.id)} onClick={() => handleDeleteMedia(m.id)}
> >
Entfernen Aus Übung entfernen
</button> </button>
</div> </div>
</div>
</li> </li>
) ))}
})}
</ul> </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 && ( {archiveOpen && (
<div <div
role="dialog" role="dialog"
@ -1558,6 +1710,16 @@ function ExerciseFormPage() {
onChange={(e) => setArchiveQ(e.target.value)} onChange={(e) => setArchiveQ(e.target.value)}
style={{ marginBottom: '8px' }} 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>} {archiveLoading && <p style={{ fontSize: '13px', color: 'var(--text3)' }}>Laden</p>}
{archiveError && <p style={{ fontSize: '13px', color: 'var(--danger)' }}>{archiveError}</p>} {archiveError && <p style={{ fontSize: '13px', color: 'var(--danger)' }}>{archiveError}</p>}
{!archiveLoading && !archiveError && archiveItems.length === 0 && ( {!archiveLoading && !archiveError && archiveItems.length === 0 && (

View File

@ -19,7 +19,6 @@ import MultiSelectCombo from '../components/MultiSelectCombo'
import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker' import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker'
import CatalogRulePicker from '../components/CatalogRulePicker' import CatalogRulePicker from '../components/CatalogRulePicker'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel' import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
import PageSectionNav from '../components/PageSectionNav' import PageSectionNav from '../components/PageSectionNav'
import { import {
INITIAL_EXERCISE_LIST_FILTERS, INITIAL_EXERCISE_LIST_FILTERS,
@ -28,7 +27,7 @@ import {
splitMnCatalogRules, splitMnCatalogRules,
splitScalarCatalogRules, splitScalarCatalogRules,
} from '../constants/exerciseListFilters' } from '../constants/exerciseListFilters'
import { coerceApiNameList } from '../utils/sanitizeHtml' import { sanitizeExerciseRichText, coerceApiNameList } from '../utils/sanitizeHtml'
import { canUserRequestExerciseDelete } from '../utils/exercisePermissions' import { canUserRequestExerciseDelete } from '../utils/exercisePermissions'
const PAGE_SIZE = 100 const PAGE_SIZE = 100
@ -1381,6 +1380,9 @@ function ExercisesListPage() {
const focusNames = exerciseFocusNames(exercise) const focusNames = exerciseFocusNames(exercise)
const styleNames = coerceApiNameList(exercise.style_direction_names) const styleNames = coerceApiNameList(exercise.style_direction_names)
const typeNames = coerceApiNameList(exercise.training_type_names) const typeNames = coerceApiNameList(exercise.training_type_names)
const summaryHtml = exercise.summary
? sanitizeExerciseRichText(exercise.summary)
: ''
return ( return (
<div key={exercise.id} className={exerciseCardClassName(exercise, user?.id)}> <div key={exercise.id} className={exerciseCardClassName(exercise, user?.id)}>
<div className="exercise-card-layout exercise-card-layout--grow"> <div className="exercise-card-layout exercise-card-layout--grow">
@ -1408,14 +1410,11 @@ function ExercisesListPage() {
<span key={`tt:${name}`} className="exercise-tag exercise-tag--training">{name}</span> <span key={`tt:${name}`} className="exercise-tag exercise-tag--training">{name}</span>
))} ))}
</div> </div>
{exercise.summary && String(exercise.summary).trim() ? ( {summaryHtml ? (
<div className="exercise-card-summary exercise-card-summary--rich"> <div
<ExerciseRichTextBlock className="exercise-card-summary exercise-card-summary--rich"
html={exercise.summary} dangerouslySetInnerHTML={{ __html: summaryHtml }}
exerciseId={exercise.id}
media={exercise.media || []}
/> />
</div>
) : null} ) : null}
</div> </div>
</div> </div>

View File

@ -1,66 +0,0 @@
/**
* 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

@ -1,85 +0,0 @@
/**
* §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

@ -1,111 +0,0 @@
/**
* 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

@ -1,17 +0,0 @@
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
}