feat: update media management and project status documentation
All checks were successful
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Successful in 24s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 27s

- Updated project status to reflect the latest media management milestones and version increment to 0.8.44.
- Enhanced MEDIA_ASSETS_AND_ARCHIVE_SPEC.md with new API details for media asset lifecycle and inline media integration.
- Improved exercise media handling in the frontend, including new preview features and user prompts for media deletion.
- Adjusted backend API to ensure proper handling of media asset deletions without removing files, maintaining governance and user experience.
This commit is contained in:
Lars 2026-05-07 13:10:37 +02:00
parent 631ba1cb43
commit e2964a077d
7 changed files with 437 additions and 195 deletions

View File

@ -1,34 +1,29 @@
# Shinkan Jinkendo - Projekt-Status
**Stand:** 2026-05-05
**Version (Code):** 0.8.10 (`backend/version.py`, APP_VERSION)
**DB-Schema-Version:** `20260505037`
**Stand:** 2026-05-07
**Version (Code):** 0.8.43 (`backend/version.py`, APP_VERSION)
**DB-Schema-Version:** `20260507045` (u. a. `media_assets`, `platform_media_storage`)
**Branch:** develop
---
## Executive Summary
**Aktueller Meilenstein:** **Trainingsrahmenprogramm Bibliothek + SlotBlueprint** (DB **036037**): Rahmenkopf nur als Vorlage mit KontextStammdaten; pro Slot genau eine **Blueprint`training_unit`** mit **`framework_unit_sections`/`_items`** wie die Planung; Kalenderliste blendet Blueprints aus; **`POST /api/training-units/from-framework-slot`** materialisiert Kopien mit **`origin_framework_slot_id`**. Parallel: **Progressionsgraph** (032034) bleibt unterstützend (**`TRAINING_FRAMEWORK_SPEC.md`** §3§4).
**Aktueller Meilenstein (Medien):** **Archiv & Wiederverwendung** auf Basis von **Migration 045** (`media_assets`, `exercise_media.media_asset_id`, Superadmin-Speicherpfad). **Papierkorb §5** (Lifecycle-API, Retention-Job), **Archiv-Liste** (`GET /api/media-assets`), **Direkt-Download** für Thumbnails (`GET /api/media-assets/{id}/file`), **Verknüpfen ohne Upload** (`POST /api/exercises/{id}/media/from-asset`), Übungsformular mit Archiv-Dialog und Vorschau sind umgesetzt.
**Letzte dokumentierte Änderungen (Mai 2026):**
**Parallel weiter relevant:** **Trainingsrahmenprogramm** (036037), **Progressionsgraph** (032034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**.
- ✅ Migration **036:** Rahmen nur Bibliothek; Fokus/Stil + M:N Trainingsarten/Zielgruppen; Entfall `plan_mode`/`group_id` am Kopf.
- ✅ Migration **037:** `training_units.framework_slot_id` / `origin_framework_slot_id`; Migration Entfall **`training_framework_slot_exercises`**.
- ✅ APIs: erweiterte RahmenHydration (`sections`, `exercises`, `blueprint_training_unit_id`); Planung siehe **`TRAINING_FRAMEWORK_SPEC.md`** §2.4.
- ✅ Frontend: `createTrainingUnitFromFrameworkSlot` in `api.js`.
**Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) · Medien-Norm: [`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`](technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md)
**Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) · Rahmen/Graph: [`technical/TRAINING_FRAMEWORK_SPEC.md`](technical/TRAINING_FRAMEWORK_SPEC.md)
**Nächste Schritte — Medien & Archiv** (neu priorisiert, Stand 2026-05-07):
**Nächste Schritte — Medien & Archiv** (siehe `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`):
1. **Übung → `official` Promotion** inkl. Medien-Anhebung + **Copyright-Pflicht** bei `official` (Spec §4.2) — höchste fachliche Lücke zur Governance.
2. **Eigenständige Medienmanager-Seite** (Admin/Verein): Metadaten/Copyright bearbeiten, Filtern nach Lifecycle, Bulk — ergänzt den in der Übung eingebetteten Archiv-Picker.
3. **Tests & Observability:** gezielte pytest-Abdeckung für Archiv/Verknüpfen; optional Retention-Job-Dry-Run dokumentieren.
4. **S3 / externes Backend** hinter Speicher-Abstraktion (Spec §7) — nach stabiler Nutzung lokaler/NAS-Pfade.
5. **Inline-Medien im Fließtext** (Spec §11) — bewusst **nach** Promotion/Copyright und tragfähigem Archiv-Workflow.
1. Papierkorb-Lebenszyklus (§5) + Hintergrundjob
2. Übungs-Promotions-Dialog inkl. Medien-Freigabe + Copyright-Pflicht bei `official`
3. Medienmanager-/Archiv-UI (Superadmin + Verein); ggf. „Aus Archiv verknüpfen“
4. S3-/ externes Backend hinter Speicher-Abstraktion
5. **Inline-Medien im Fließtext** erst nach stabilem Archiv (Spec §11 — Platzhalter, zentraler Renderer)
**Inline:** Leitplanken in **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §11** festgehalten; Umsetzung bewusst verschoben, um Refactor zu vermeiden.
**Inline:** Leitplanken in **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §11**; kein Big-Bang vor stabiler Archiv-/Governance-Basis.
---
@ -55,6 +50,7 @@
| **030** | **training_unit_exercises.exercise_variant_id** | ✅ | 🔲 |
| **032034** | **Progressionsgraph Übung→Übung** | ✅ | 🔲 |
| **035037** | **Rahmenprogramm, BibliothekKopf, SlotBlueprintUnits** | ✅ | 🔲 |
| **040045** | **u. a. Mitgliedschaften, Übungs-Governance, `media_assets`, Plattform-Speicherpfad** | ✅ | 🔲 |
---
@ -107,8 +103,10 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu
### 🔲 In Arbeit / Backlog
- [ ] **Medien:** Papierkorb (§5), Promotion/Copyright, Archiv-UI, S3 — Roadmap oben im Executive Summary
- [ ] **Medien:** Inline im Fließtext — erst nach Archiv-Stabilität (**`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §11**)
- [x] **Medien:** Papierkorb (§5), Retention-Job, Archiv-API, „Aus Archiv verknüpfen“, Picker/Vorschau in Übungsbearbeitung (Release 0.8.42 ff.)
- [ ] **Medien:** Promotion Übung↔Medien + Copyright-Pflicht `official` (Spec §4.2)
- [ ] **Medien:** Eigenständige Medienmanager-UI (Admin/Verein), S3 — Roadmap Executive Summary
- [ ] **Medien:** Inline im Fließtext — nach Spec §11, nach Promotion/Archiv-Reife
- [ ] Admin-UI für Skill-Kategorien (CRUD) falls noch offen
- [ ] Responsive Design / Dark Mode / PWA
- [ ] KI-Suche (`ai_search`) über reine Volltextsuche hinaus
@ -139,7 +137,7 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu
### Dev
Branch `develop`; Migrations bis mindestens **037** auf dem aktuellen Entwicklungsstand; Details in `backend/version.py`.
Branch `develop`; Migrations bis mindestens **045** auf dem aktuellen Entwicklungsstand; Details in `backend/version.py`.
### Prod
@ -161,7 +159,7 @@ Deployment der oben genannten Migrationen und Datenabgleich nach internem Prozes
| Search & Filter | `technical/SEARCH_FILTER_SPEC.md` | 2026-04-27 | ✅ Aktualisiert (Liste UX) |
| Media Upload | `technical/MEDIA_UPLOAD_SPEC.md` | 2026-04-27 | ✅ Aktualisiert (Limits) |
| Medien-Archiv & Lifecycle | `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` | 2026-05-07 | ✅ §11 Inline-Plan, Drift-Tab |
| Projektstatus | `PROJECT_STATUS.md` | 2026-05-07 | ✅ Diese Datei |
| Projektstatus | `PROJECT_STATUS.md` | 2026-05-07 | ✅ Medien-Meilenstein aktualisiert |
---

View File

@ -188,7 +188,7 @@ Das widerspricht **nicht** dem Papierkorb: wer kein Recht hat, ein Asset **globa
|-------|----------|
| 2026-05-07 | Erstfassung als Single Source of Truth (verbindlich); Abstimmung mit Stakeholder: Promotion Übung↔Medien, Copyright, Papierkorb 3-stufig, externe Speicher, Embeds getrennt vom Asset-Lifecycle. |
| 2026-05-07 | §11 **Inline-Medien im Fließtext**: Leitplanken (Anker `exercise_media.id`, einheitlicher Render-Pfad, keine zweite Governance); Zeitpunkt der Umsetzung; Drift-Vermeidung ohne jetzigen Vollbau. |
| 2026-05-07 | §7.1 Konfiguration Bootstrap vs. Superadmin (`platform_media_storage`), NAS/Mount-Hinweis, Drift-Regel. Umsetzung Start: DB `media_assets`, FK `exercise_media.media_asset_id`, API Speichereinstellungen Superadmin. |
| 2026-05-07 | Archiv-API: `GET /api/media-assets`, `GET /api/media-assets/{id}/file`; `POST /api/exercises/{id}/media/from-asset`; UI Picker/Vorschau. Papierkorb/Retention wie vorhergehende Releases. |
---

View File

@ -33,7 +33,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
**Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen.
Letzte Änderung: 2026-05-07 — Medienarchiv `GET /api/media-assets`, `GET …/file`, Übung `POST …/media/from-asset`; Lifecycle siehe oben.
Letzte Änderung: 2026-05-07 — `DELETE …/media/{id}` nur Verknüpfung, optional `orphan_media_asset_id`; übriges siehe Medienzeilen oben.
---

View File

@ -2433,16 +2433,19 @@ def delete_exercise_media(
media_id: int,
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Entfernt nur die Verknüpfung Übung exercise_media (bzw. Embed-Zeile).
Löscht keine Datei und keinen media_assets-Datensatz optionales Papierkorb-Angebot nur clientseitig
über orphan_media_asset_id, wenn dies die letzte Referenz auf das Asset war.
"""
profile_id = tenant.profile_id
unlink_path: Optional[Path] = None
orphan_media_asset_id: Optional[int] = None
with get_db() as conn:
cur = get_cursor(conn)
_assert_can_edit_exercise(cur, exercise_id, tenant)
media_root = get_effective_media_root(cur)
cur.execute(
"""SELECT em.file_path, em.media_asset_id, ma.storage_key AS asset_storage_key
"""SELECT em.media_asset_id
FROM exercise_media em
LEFT JOIN media_assets ma ON ma.id = em.media_asset_id
WHERE em.id = %s AND em.exercise_id = %s""",
(media_id, exercise_id),
)
@ -2450,41 +2453,19 @@ def delete_exercise_media(
if not row:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
rec = r2d(row)
fp = rec.get("file_path")
media_asset_id = rec.get("media_asset_id")
asset_storage_key = rec.get("asset_storage_key")
cur.execute(
"DELETE FROM exercise_media WHERE id = %s AND exercise_id = %s",
(media_id, exercise_id),
)
if media_asset_id:
cur.execute(
"SELECT COUNT(*) AS c FROM exercise_media WHERE media_asset_id = %s",
(media_asset_id,),
)
cnt = int(r2d(cur.fetchone())["c"])
if cnt == 0:
cur.execute(
"DELETE FROM media_assets WHERE id = %s RETURNING storage_key",
(media_asset_id,),
)
del_asset = cur.fetchone()
sk = None
if del_asset:
sk = r2d(del_asset).get("storage_key") or asset_storage_key
if sk:
unlink_path = path_under_media_root(media_root, sk)
elif fp:
unlink_path = _abs_media_path(fp, media_root)
if int(r2d(cur.fetchone())["c"]) <= 1:
orphan_media_asset_id = int(media_asset_id)
cur.execute(
"DELETE FROM exercise_media WHERE id = %s AND exercise_id = %s",
(media_id, exercise_id),
)
conn.commit()
if unlink_path and unlink_path.is_file():
try:
unlink_path.unlink()
except OSError as e:
logger.warning("Medien-Datei konnte nicht gelöscht werden: %s", e)
return {"ok": True}
return {"ok": True, "orphan_media_asset_id": orphan_media_asset_id}

View File

@ -0,0 +1,273 @@
"""
Medienarchiv: GET /api/media-assets und POST /api/exercises/{id}/media/from-asset (gemockte DB).
"""
from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
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 _mock_db(mock_cur: MagicMock) -> MagicMock:
mock_conn = MagicMock()
mock_cm = MagicMock()
mock_cm.__enter__.return_value = mock_conn
mock_cm.__exit__.return_value = False
return mock_cm
def test_list_media_assets_ok_mocked(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=10,
global_role="trainer",
effective_club_id=5,
club_ids=frozenset({5}),
memberships=[],
)
mock_cur = MagicMock()
mock_cur.fetchall.return_value = [
{
"id": 1,
"mime_type": "image/png",
"byte_size": 100,
"original_filename": "a.png",
"visibility": "official",
"club_id": None,
"uploaded_by_profile_id": 2,
"lifecycle_state": "active",
"created_at": None,
"sha256": "a" * 64,
}
]
mock_cm = _mock_db(mock_cur)
with patch("routers.media_assets.get_db", return_value=mock_cm), patch(
"routers.media_assets.get_cursor", return_value=mock_cur
):
r = client.get("/api/media-assets?q=test", headers={"X-Auth-Token": "t"})
assert r.status_code == 200
body = r.json()
assert body["limit"] == 30
assert len(body["items"]) == 1
assert body["items"][0]["original_filename"] == "a.png"
def test_attach_from_asset_duplicate_returns_400(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=None,
club_ids=frozenset(),
memberships=[],
)
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{"created_by": 1, "visibility": "private", "club_id": None},
{"c": 0},
{
"id": 5,
"mime_type": "image/jpeg",
"byte_size": 10,
"original_filename": "x.jpg",
"visibility": "official",
"club_id": None,
"uploaded_by_profile_id": 1,
"lifecycle_state": "active",
"storage_key": "exercises/x.jpg",
},
{"id": 1},
]
mock_cm = _mock_db(mock_cur)
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
"routers.exercises.get_cursor", return_value=mock_cur
):
r = client.post(
"/api/exercises/3/media/from-asset",
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
json={
"media_asset_id": 5,
"title": "",
"description": "",
"context": "ablauf",
"is_primary": False,
},
)
assert r.status_code == 400
assert "bereits" in (r.json().get("detail") or "").lower()
def test_attach_from_asset_ok_mocked(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=None,
club_ids=frozenset(),
memberships=[],
)
inserted = {
"id": 99,
"exercise_id": 3,
"media_type": "image",
"file_path": "/media/exercises/h.jpg",
"file_size": 10,
"mime_type": "image/jpeg",
"original_filename": "h.jpg",
"embed_url": None,
"embed_platform": None,
"title": "h.jpg",
"description": None,
"sort_order": 1,
"is_primary": False,
"context": "ablauf",
"created_at": None,
"media_asset_id": 5,
}
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{"created_by": 1, "visibility": "private", "club_id": None},
{"c": 0},
{
"id": 5,
"mime_type": "image/jpeg",
"byte_size": 10,
"original_filename": "h.jpg",
"visibility": "official",
"club_id": None,
"uploaded_by_profile_id": 1,
"lifecycle_state": "active",
"storage_key": "exercises/h.jpg",
},
None,
inserted,
]
mock_cm = _mock_db(mock_cur)
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
"routers.exercises.get_cursor", return_value=mock_cur
):
r = client.post(
"/api/exercises/3/media/from-asset",
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
json={
"media_asset_id": 5,
"context": "detail",
"is_primary": False,
},
)
assert r.status_code == 201
body = r.json()
assert body["id"] == 99
assert body["media_asset_id"] == 5
assert body["asset_lifecycle_state"] == "active"
def test_delete_exercise_media_returns_orphan_when_last_ref(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=None,
club_ids=frozenset(),
memberships=[],
)
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{"created_by": 1, "visibility": "private", "club_id": None},
{"media_asset_id": 88},
{"c": 1},
]
mock_cm = _mock_db(mock_cur)
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
"routers.exercises.get_cursor", return_value=mock_cur
):
r = client.delete("/api/exercises/10/media/20", headers={"X-Auth-Token": "t"})
assert r.status_code == 200
body = r.json()
assert body["ok"] is True
assert body["orphan_media_asset_id"] == 88
def test_delete_exercise_media_no_orphan_when_shared(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=None,
club_ids=frozenset(),
memberships=[],
)
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{"created_by": 1, "visibility": "private", "club_id": None},
{"media_asset_id": 88},
{"c": 2},
]
mock_cm = _mock_db(mock_cur)
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
"routers.exercises.get_cursor", return_value=mock_cur
):
r = client.delete("/api/exercises/10/media/20", headers={"X-Auth-Token": "t"})
assert r.status_code == 200
assert r.json()["orphan_media_asset_id"] is None
def test_delete_exercise_media_embed_row_no_orphan(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=None,
club_ids=frozenset(),
memberships=[],
)
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{"created_by": 1, "visibility": "private", "club_id": None},
{"media_asset_id": None},
]
mock_cm = _mock_db(mock_cur)
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
"routers.exercises.get_cursor", return_value=mock_cur
):
r = client.delete("/api/exercises/10/media/21", headers={"X-Auth-Token": "t"})
assert r.status_code == 200
assert r.json() == {"ok": True, "orphan_media_asset_id": None}

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.43"
APP_VERSION = "0.8.44"
BUILD_DATE = "2026-05-07"
DB_SCHEMA_VERSION = "20260507045"
@ -17,7 +17,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
"exercises": "2.13.0", # POST /media/from-asset (Archiv verknüpfen)
"exercises": "2.14.0", # DELETE media: nur Verknüpfung; optional orphan_media_asset_id
"training_units": "0.2.0",
"training_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
@ -29,6 +29,14 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.44",
"date": "2026-05-07",
"changes": [
"DELETE /api/exercises/{id}/media/{mid}: entfernt nur exercise_media; keine Datei-/media_assets-Löschung; Response orphan_media_asset_id wenn letzte Referenz",
"Übung bearbeiten: Video-Kachel (Erstframe), Dateiname; Papierkorb-Schalter entfernt; „Aus Übung entfernen“ + optional Papierkorb bei Waise",
],
},
{
"version": "0.8.43",
"date": "2026-05-07",

View File

@ -6,6 +6,70 @@ import RichTextEditor from '../components/RichTextEditor'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
/** Kachelvorschau: Video nutzt ersten Frame (metadata), Bild = img. */
function ExerciseMediaThumbTile({ exerciseId, media, onOpenPreview }) {
const src = !media.embed_url ? resolveExerciseMediaFileUrl(exerciseId, media) : null
const commonStyle = {
width: '100%',
height: '100%',
objectFit: 'cover',
}
return (
<div
role="button"
tabIndex={0}
title="Vorschau"
onClick={() => onOpenPreview(media)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onOpenPreview(media)
}
}}
style={{
width: 72,
height: 72,
flexShrink: 0,
borderRadius: '8px',
overflow: 'hidden',
background: 'var(--surface2, rgba(127,127,127,0.12))',
border: '1px solid var(--border)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{media.embed_url ? (
<span style={{ fontSize: '11px', padding: '4px', color: 'var(--text2)', textAlign: 'center' }}>
{media.embed_platform || 'Embed'}
</span>
) : (media.mime_type?.startsWith('image/') || media.media_type === 'image') && src ? (
<img alt="" src={src} style={commonStyle} />
) : (media.mime_type?.startsWith('video/') || media.media_type === 'video') && src ? (
<video
src={src}
muted
playsInline
preload="metadata"
style={{ ...commonStyle, pointerEvents: 'none' }}
onLoadedMetadata={(e) => {
try {
const el = e.currentTarget
const d = el.duration
el.currentTime = Number.isFinite(d) && d > 0 ? Math.min(0.05, d * 0.01) : 0.05
} catch (_) {
/* ignore */
}
}}
/>
) : (
<span style={{ fontSize: '11px', color: 'var(--text2)' }}>Datei</span>
)}
</div>
)
}
const INTENSITY_OPTIONS = [
{ value: '', label: '—' },
{ value: 'niedrig', label: 'niedrig' },
@ -657,25 +721,32 @@ function ExerciseFormPage() {
}
const handleDeleteMedia = async (mid) => {
if (!confirm('Medium löschen?')) return
if (
!confirm(
'Dieses Medium aus der Übung entfernen? Nur die Verknüpfung wird gelöscht. Die Datei bleibt im Archiv, solange sie noch woanders genutzt wird.',
)
) {
return
}
try {
await api.deleteExerciseMedia(exerciseId, mid)
const res = await api.deleteExerciseMedia(exerciseId, mid)
await refreshMedia()
const oid = res?.orphan_media_asset_id
if (oid != null) {
if (
confirm(
'Dieses Archiv-Medium wird danach nirgendwo mehr verwendet. In den Papierkorb (Stufe 1) legen? (Später in der Medienverwaltung wiederherstellbar.)',
)
) {
await api.postMediaAssetLifecycle(oid, 'trash_soft')
await refreshMedia()
}
}
} catch (err) {
alert(err.message)
}
}
const runMediaLifecycle = async (assetId, action) => {
if (!assetId) return
try {
await api.postMediaAssetLifecycle(assetId, action)
await refreshMedia()
} catch (e) {
alert(e.message || String(e))
}
}
const moveMediaRow = async (idx, dir) => {
if (!exerciseId) return
const j = idx + dir
@ -1282,8 +1353,8 @@ function ExerciseFormPage() {
<div className="card" style={{ marginTop: '16px' }}>
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Medien</h2>
<p style={{ color: 'var(--text2)', fontSize: '13px' }}>
Datei oder Embed (YouTube, Vimeo, Instagram, TikTok). Max. 10 pro Übung. Archiv-Medien können mehrfach
verknüpft werden.
Datei oder Embed (YouTube, Vimeo, Instagram, TikTok). Max. 10 pro Übung. Archiv-Medien mehrfach nutzbar.
Papierkorb und Freigaben steuern Sie zentral im Archiv hier nur Verknüpfung zur Übung.
</p>
<div
style={{
@ -1375,43 +1446,7 @@ function ExerciseFormPage() {
alignItems: 'flex-start',
}}
>
<button
type="button"
title="Vorschau"
onClick={() => setMediaPreview(m)}
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)',
padding: 0,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{m.embed_url ? (
<span style={{ fontSize: '11px', padding: '4px', color: 'var(--text2)', textAlign: 'center' }}>
{m.embed_platform || 'Embed'}
</span>
) : m.mime_type?.startsWith('image/') ? (
<img
alt=""
src={resolveExerciseMediaFileUrl(exerciseId, m)}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : m.mime_type?.startsWith('video/') ? (
<span style={{ fontSize: '22px', opacity: 0.75 }} aria-hidden>
</span>
) : (
<span style={{ fontSize: '11px', color: 'var(--text2)' }}>Datei</span>
)}
</button>
<ExerciseMediaThumbTile exerciseId={exerciseId} media={m} onOpenPreview={setMediaPreview} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
@ -1443,82 +1478,32 @@ function ExerciseFormPage() {
</>
)}
</div>
{m.media_asset_id ? (
<div style={{ marginTop: '8px', fontSize: '12px' }}>
{String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_soft' && (
<p style={{ color: 'var(--danger)', margin: '0 0 6px' }}>
Papierkorb (Stufe 1): für neue Zuordnungen gesperrt.
</p>
)}
{String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_hidden' && (
<p style={{ color: 'var(--danger)', margin: '0 0 6px' }}>
Ausgeblendet (Stufe 2): in der Übungsansicht nicht sichtbar.
</p>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
{String(m.asset_lifecycle_state || 'active').toLowerCase() === 'active' && (
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }}
onClick={() => {
if (confirm('Medium in den Papierkorb (Stufe 1)?')) {
runMediaLifecycle(m.media_asset_id, 'trash_soft')
}
}}
>
Papierkorb (Stufe 1)
</button>
)}
{['active', 'trash_soft'].includes(String(m.asset_lifecycle_state || 'active').toLowerCase()) && (
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }}
onClick={() => {
if (confirm('Medium ausblenden (Stufe 2)? Lesende Nutzer sehen es nicht mehr.')) {
runMediaLifecycle(m.media_asset_id, 'trash_hidden')
}
}}
>
Ausblenden (Stufe 2)
</button>
)}
{String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_hidden' && (
<>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }}
onClick={() => runMediaLifecycle(m.media_asset_id, 'recover')}
>
Wiederherstellen (Stufe 1)
</button>
<button
type="button"
className="btn"
style={{
fontSize: '11px',
padding: '4px 8px',
background: 'var(--danger)',
color: '#fff',
border: 'none',
}}
onClick={() => {
if (
confirm('Endgültig löschen? Entfernt Datei und Verknüpfungen dieser Medien-ID.')
) {
runMediaLifecycle(m.media_asset_id, 'purge')
}
}}
>
Endgültig löschen
</button>
</>
)}
</div>
</div>
) : null}
<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>
{m.media_asset_id &&
String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_soft' && (
<p style={{ color: 'var(--danger)', margin: '8px 0 0', fontSize: '12px' }}>
Hinweis: Dieses Archiv-Medium ist im Papierkorb (Stufe 1).
</p>
)}
{m.media_asset_id &&
String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_hidden' && (
<p style={{ color: 'var(--danger)', margin: '8px 0 0', fontSize: '12px' }}>
Hinweis: Archiv-Medium ausgeblendet (Stufe 2) in der Übungsansicht unsichtbar.
</p>
)}
<div className="form-row" style={{ marginTop: '8px', display: 'grid', gap: '8px' }}>
<input
type="text"
@ -1562,18 +1547,15 @@ function ExerciseFormPage() {
</div>
<button
type="button"
className="btn"
className="btn btn-secondary"
style={{
marginTop: '8px',
fontSize: '11px',
padding: '4px 10px',
background: 'var(--danger)',
color: '#fff',
border: 'none',
fontSize: '12px',
padding: '6px 12px',
}}
onClick={() => handleDeleteMedia(m.id)}
>
Löschen
Aus Übung entfernen
</button>
</div>
</li>
@ -1740,13 +1722,13 @@ function ExerciseFormPage() {
{mediaPreview.embed_url}
</a>
</p>
) : mediaPreview.mime_type?.startsWith('video/') ? (
) : mediaPreview.mime_type?.startsWith('video/') || mediaPreview.media_type === 'video' ? (
<video
src={resolveExerciseMediaFileUrl(exerciseId, mediaPreview)}
controls
style={{ width: '100%', borderRadius: '8px', maxHeight: '70vh' }}
/>
) : mediaPreview.mime_type?.startsWith('image/') ? (
) : mediaPreview.mime_type?.startsWith('image/') || mediaPreview.media_type === 'image' ? (
<img
alt=""
src={resolveExerciseMediaFileUrl(exerciseId, mediaPreview)}