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.47–0.8.57 (Auszug):** Übung **`official`** nur Superadmin; Vereinsübungen mit File-Assets: **Copyright-Pflicht**; Speicherpfade **`library/`** mit Vereinsordner (Name+c{id}), Medienkind-Unterordner, Governance-Umzug bei Sichtbarkeit; Bibliothek-GET mit Filtern/Tags/Nutzungs-Anzeige; Bulk-Lifecycle/PATCH; Lesemodus/Kacheln; Konflikt **409** bei Upload-Dedupe vs. Papierkorb + UI-Reaktivierung. | | 2026-05-07 | **0.8.47–0.8.57 (Auszug):** Übung **`official`** nur Superadmin; Vereinsübungen mit File-Assets: **Copyright-Pflicht**; Speicherpfade **`library/`** mit Vereinsordner (Name+c{id}), Medienkind-Unterordner, Governance-Umzug bei Sichtbarkeit; Bibliothek-GET mit Filtern/Tags/Nutzungs-Anzeige; Bulk-Lifecycle/PATCH; Lesemodus/Kacheln; Konflikt **409** bei Upload-Dedupe vs. Papierkorb + UI-Reaktivierung. |
@ -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.1–11.4; Editor-Einstieg „Medium einfügen“ in Übungsformular (Bearbeitungsmodus). 2. **Als Nächstes (geplant):** Inline implementieren gemäß §11.1–11.4 — Trainer-Feedback/Content-Menge kann Priorität schärfen; technische Leitplanken hier sind verbindlich.
(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"> Bearbeiten
{fromExerciseEdit ? 'Zurück zur Bearbeitung' : '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,125 +1487,193 @@ function ExerciseFormPage() {
Medienbibliothek Medienbibliothek
</Link> </Link>
</div> </div>
<div style={{ display: 'grid', gap: '12px', marginTop: '12px' }}>
<div>
<label className="form-label">Dateien</label>
<input
type="file"
multiple
accept="image/*,video/*,application/pdf"
onChange={(e) => {
setMediaFiles(Array.from(e.target.files || []))
e.target.value = ''
}}
/>
{mediaFiles.length > 0 && (
<div style={{ fontSize: '0.875rem', color: 'var(--text2)', marginTop: '6px' }}>
{mediaFiles.length} Datei(en): {mediaFiles.map((f) => f.name).join(', ')}
</div>
)}
<div className="form-row" style={{ marginTop: '8px' }}>
<select className="form-input" value={mediaType} onChange={(e) => setMediaType(e.target.value)}>
<option value="image">Typ-Fallback: Bild</option>
<option value="video">Typ-Fallback: Video</option>
<option value="document">Typ-Fallback: PDF</option>
</select>
<input
type="text"
className="form-input"
placeholder="Titel (optional)"
value={mediaTitle}
onChange={(e) => setMediaTitle(e.target.value)}
style={{ marginTop: '8px' }}
/>
<select
className="form-input"
value={mediaContext}
onChange={(e) => setMediaContext(e.target.value)}
style={{ marginTop: '8px' }}
>
<option value="ablauf">Ablauf</option>
<option value="detail">Detail</option>
<option value="trainer_hint">Trainer-Hinweis</option>
</select>
</div>
<button type="button" className="btn btn-secondary" style={{ marginTop: '8px' }} onClick={handleUploadFile}>
Hochladen
</button>
</div>
<div>
<label className="form-label">Embed-URL</label>
<input
type="url"
className="form-input"
placeholder="https://…"
value={embedUrl}
onChange={(e) => setEmbedUrl(e.target.value)}
/>
<input
type="text"
className="form-input"
placeholder="Titel (optional)"
value={embedTitle}
onChange={(e) => setEmbedTitle(e.target.value)}
style={{ marginTop: '8px' }}
/>
<button type="button" className="btn btn-secondary" style={{ marginTop: '8px' }} onClick={handleAddEmbed}>
Embed hinzufügen
</button>
</div>
</div>
{mediaList.length > 0 && ( {mediaList.length > 0 && (
<ul className="exercise-edit-media-strip"> <ul style={{ marginTop: '12px', paddingLeft: '0', listStyle: 'none' }}>
{mediaList.map((m, idx) => { {mediaList.map((m, idx) => (
const cap = <li
(m.title || '').trim() || key={m.id}
(m.original_filename || '').trim() || className="card"
(m.embed_url ? String(m.embed_url).replace(/^https?:\/\//i, '').slice(0, 80) : '') style={{
const sub = [m.media_type, m.embed_platform].filter(Boolean).join(' · ') || 'Medium' marginBottom: '10px',
const payloadCaption = ( padding: '10px 12px',
[m.title, m.original_filename].find((x) => typeof x === 'string' && x.trim()) || '' border: '1px solid var(--border)',
).trim() display: 'flex',
return ( gap: '12px',
<li key={m.id} className="exercise-edit-media-strip__item"> alignItems: 'flex-start',
<div className="exercise-edit-media-strip__lead"> }}
{!m.embed_url ? ( >
<ExerciseMediaThumbTile exerciseId={exerciseId} media={m} onOpenPreview={setMediaPreview} size={76} /> <ExerciseMediaThumbTile exerciseId={exerciseId} media={m} onOpenPreview={setMediaPreview} />
) : ( <div style={{ flex: 1, minWidth: 0 }}>
<div <div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
className="exercise-edit-media-strip__embed-badge exercise-edit-media-strip__embed-badge--solo" <span style={{ fontSize: '12px', color: 'var(--text3)' }}>
aria-hidden #{idx + 1} · {m.media_type}
> {m.embed_platform ? ` · ${m.embed_platform}` : ''}
{m.embed_platform || 'Embed'} </span>
</div> {mediaList.length > 1 && (
)} <>
<div
className="exercise-edit-media-strip__handle"
title="Mit Drag und Drop in ein Textfeld ziehen"
draggable
onDragStart={(e) => {
try {
e.dataTransfer.setData(
SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
buildExerciseMediaDragPayload(m.id, payloadCaption),
)
e.dataTransfer.effectAllowed = 'copy'
} catch (_) {
/* ignore */
}
}}
>
⣿<span className="exercise-edit-media-strip__handle-text"> Ziehen</span>
</div>
</div>
<div className="exercise-edit-media-strip__body">
<div className="exercise-edit-media-strip__headline">
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
#{m.id} · {sub}
</span>
</div>
<div style={{ fontSize: '13px', color: 'var(--text2)', lineHeight: 1.35 }}>{cap || '—'}</div>
<div className="exercise-edit-media-strip__toolbar">
<input
type="text"
className="form-input exercise-edit-media-strip__title"
placeholder="Titel (wird in der Vorschau und im Platzhalter genutzt)"
value={(mediaFields[m.id] || {}).title ?? ''}
onChange={(e) =>
setMediaFields((prev) => ({
...prev,
[m.id]: {
title: e.target.value,
},
}))
}
/>
</div>
<div className="exercise-edit-media-strip__actions">
{mediaList.length > 1 && (
<>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }}
disabled={idx === 0}
onClick={() => moveMediaRow(idx, -1)}
title="Nach oben"
>
↑
</button>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }}
disabled={idx >= mediaList.length - 1}
onClick={() => moveMediaRow(idx, 1)}
title="Nach unten"
>
↓
</button>
</>
)}
<button <button
type="button" type="button"
className="btn btn-primary" className="btn btn-secondary"
style={{ fontSize: '12px', padding: '4px 10px' }} style={{ fontSize: '11px', padding: '2px 8px' }}
disabled={mediaSavingId === m.id} disabled={idx === 0}
onClick={() => saveMediaMeta(m.id)} onClick={() => moveMediaRow(idx, -1)}
title="Nach oben"
> >
{mediaSavingId === m.id ? '…' : 'Speichern'} ↑
</button> </button>
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
style={{ fontSize: '12px', padding: '4px 10px' }} style={{ fontSize: '11px', padding: '2px 8px' }}
onClick={() => handleDeleteMedia(m.id)} disabled={idx >= mediaList.length - 1}
onClick={() => moveMediaRow(idx, 1)}
title="Nach unten"
> >
Entfernen ↓
</button> </button>
</div> </>
</div> )}
</li> </div>
) <div
})} style={{
fontSize: '12px',
color: 'var(--text2)',
marginTop: '6px',
wordBreak: 'break-word',
lineHeight: 1.35,
}}
>
{(m.original_filename || '').trim() ||
(m.title || '').trim() ||
(m.embed_url ? m.embed_url : '') ||
'—'}
</div>
<div className="form-row" style={{ marginTop: '8px', display: 'grid', gap: '8px' }}>
<input
type="text"
className="form-input"
placeholder="Titel"
value={(mediaFields[m.id] || {}).title ?? ''}
onChange={(e) =>
setMediaFields((prev) => ({
...prev,
[m.id]: { ...(prev[m.id] || {}), title: e.target.value, context: (prev[m.id] || {}).context || 'ablauf' },
}))
}
/>
<select
className="form-input"
value={(mediaFields[m.id] || {}).context || 'ablauf'}
onChange={(e) =>
setMediaFields((prev) => ({
...prev,
[m.id]: {
...(prev[m.id] || {}),
title: (prev[m.id] || {}).title ?? '',
context: e.target.value,
},
}))
}
>
<option value="ablauf">Ablauf</option>
<option value="detail">Detail</option>
<option value="trainer_hint">Trainer-Hinweis</option>
</select>
<button
type="button"
className="btn btn-primary"
style={{ fontSize: '12px' }}
disabled={mediaSavingId === m.id}
onClick={() => saveMediaMeta(m.id)}
>
{mediaSavingId === m.id ? 'Speichern…' : 'Titel & Sektion speichern'}
</button>
</div>
<button
type="button"
className="btn btn-secondary"
style={{
marginTop: '8px',
fontSize: '12px',
padding: '6px 12px',
}}
onClick={() => handleDeleteMedia(m.id)}
>
Aus Übung entfernen
</button>
</div>
</li>
))}
</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
}