diff --git a/.claude/docs/PROJECT_STATUS.md b/.claude/docs/PROJECT_STATUS.md index d84991a..9bcd706 100644 --- a/.claude/docs/PROJECT_STATUS.md +++ b/.claude/docs/PROJECT_STATUS.md @@ -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 + Slot‑Blueprint** (DB **036–037**): Rahmenkopf nur als Vorlage mit Kontext‑Stammdaten; 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** (032–034) 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** (036–037), **Progressionsgraph** (032–034) — 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 Rahmen‑Hydration (`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** | ✅ | 🔲 | | **032–034** | **Progressionsgraph Übung→Übung** | ✅ | 🔲 | | **035–037** | **Rahmenprogramm, Bibliothek‑Kopf, Slot‑Blueprint‑Units** | ✅ | 🔲 | +| **040–045** | **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 | --- diff --git a/.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md b/.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md index 0f13134..0c4b667 100644 --- a/.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md +++ b/.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md @@ -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. | --- diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index 1add3d3..3bc0a40 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -33,7 +33,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. **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. --- diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index f620aac..5d295fc 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -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} diff --git a/backend/tests/test_media_assets_archive.py b/backend/tests/test_media_assets_archive.py new file mode 100644 index 0000000..f558d4d --- /dev/null +++ b/backend/tests/test_media_assets_archive.py @@ -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} diff --git a/backend/version.py b/backend/version.py index 4194aee..47ef902 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx index 94bf5b1..a4acd0d 100644 --- a/frontend/src/pages/ExerciseFormPage.jsx +++ b/frontend/src/pages/ExerciseFormPage.jsx @@ -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 ( +
- 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.
- Papierkorb (Stufe 1): für neue Zuordnungen gesperrt. -
- )} - {String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_hidden' && ( -- Ausgeblendet (Stufe 2): in der Übungsansicht nicht sichtbar. -
- )} -+ Hinweis: Dieses Archiv-Medium ist im Papierkorb (Stufe 1). +
+ )} + {m.media_asset_id && + String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_hidden' && ( ++ Hinweis: Archiv-Medium ausgeblendet (Stufe 2) — in der Übungsansicht unsichtbar. +
+ )}