feat: enhance media asset management and exercise integration
All checks were successful
Deploy Development / deploy (push) Successful in 33s
Test Suite / pytest-backend (push) Successful in 23s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 23s

- Added new API endpoints for listing media assets and attaching existing archive media to exercises, improving media reuse and governance.
- Updated frontend components to support media asset selection from the archive, enhancing user experience and reducing duplication.
- Incremented version to 0.8.43, reflecting the latest changes in media handling and exercise integration.
This commit is contained in:
Lars 2026-05-07 13:00:57 +02:00
parent 8ac723eafe
commit 631ba1cb43
7 changed files with 610 additions and 20 deletions

View File

@ -19,6 +19,9 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
| admin_users | `GET /api/admin/users` | Plattform | `require_auth` | Admin-Rolle | EXEMPT `check_access_layer_hints.py` |
| platform_media_storage | `GET/PUT /api/admin/platform-media-storage` | Plattform | `require_auth` | GET: `is_platform_admin`; PUT: nur `superadmin` | Relativer Pfad unter `MEDIA_ROOT`; keine Secrets; EXEMPT wie admin_users |
| media_assets | `POST /api/media-assets/{id}/lifecycle` | ja | `get_tenant_context` | ja | Papierkorb: `trash_soft` / `trash_hidden` / `recover` / `purge`; Rechte über Uploader, `can_manage_club_org`, Superadmin (`assert_can_manage_media_asset_lifecycle`) |
| media_assets | `GET /api/media-assets` | ja | `get_tenant_context` | ja | Archiv-Liste: nur `lifecycle_state=active`; Sichtbarkeit wie Bibliothek (official / eigenes private / Vereinsmitglied) |
| media_assets | `GET /api/media-assets/{id}/file` | ja | `get_tenant_context_flexible` | ja | Direkt-Download für Archiv-Thumbs; `?ssetoken`; nur wenn Asset sichtbar |
| exercises | `POST /api/exercises/{id}/media/from-asset` | ja | `get_tenant_context` | ja | Verknüpfung `exercise_media` → bestehendes `media_asset_id`; Bearbeitungsrecht Übung + Leserecht Archiv |
| auth | `/api/auth/*` | nein | — | Login/Session | EXEMPT |
| catalogs | Katalog-CRUD | nein (global) | `require_auth` | Admin/Trainer je Endpoint | EXEMPT; bei späterem `club_id` nachziehen |
| skills | `/api/skills*` | nein (global) | `require_auth` | je Endpoint | EXEMPT |
@ -30,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 — `media_assets` Lifecycle-API (Papierkorb); `platform_media_storage` Admin-API (Speicherpfad Superadmin).
Letzte Änderung: 2026-05-07 — Medienarchiv `GET /api/media-assets`, `GET …/file`, Übung `POST …/media/from-asset`; Lifecycle siehe oben.
---

View File

@ -209,6 +209,17 @@ class ExerciseMediaReorder(BaseModel):
media_ids: list[int]
class ExerciseMediaFromAsset(BaseModel):
"""Bestehendes Archiv-Medium (media_assets) mit Übung verknüpfen — ohne erneuten Upload."""
media_asset_id: int = Field(..., ge=1)
title: Optional[str] = ""
description: Optional[str] = ""
context: str = "ablauf"
is_primary: bool = False
media_type: Optional[str] = None
class ExerciseVariantCreate(BaseModel):
variant_name: str = Field(..., min_length=3, max_length=200)
description: Optional[str] = None
@ -302,6 +313,17 @@ def _detect_embed_platform(url: str) -> Optional[str]:
return None
def _media_type_from_mime(mime: Optional[str]) -> str:
m = (mime or "").strip().lower()
if m.startswith("image/"):
return "image"
if m.startswith("video/"):
return "video"
if m == "application/pdf":
return "document"
return "document"
def _fetch_exercise_governance_row(cur, exercise_id: int) -> Optional[dict]:
cur.execute(
"SELECT id, visibility, club_id, created_by FROM exercises WHERE id = %s",
@ -2222,6 +2244,115 @@ async def upload_exercise_media(
return r2d(row)
@router.post("/exercises/{exercise_id}/media/from-asset", status_code=201)
def attach_exercise_media_from_asset(
exercise_id: int,
body: ExerciseMediaFromAsset,
tenant: TenantContext = Depends(get_tenant_context),
):
"""Archiv-Medium an Übung anhängen (Wiederverwendung, deduplizierte Datei)."""
profile_id = tenant.profile_id
ctx = (body.context or "ablauf").strip()
if ctx not in ("ablauf", "detail", "trainer_hint"):
raise HTTPException(status_code=400, detail="Ungültiger context")
mt_in = body.media_type
if mt_in is not None and mt_in not in ("image", "video", "document", "sketch"):
raise HTTPException(status_code=400, detail="Ungültiger media_type")
with get_db() as conn:
cur = get_cursor(conn)
_assert_can_edit_exercise(cur, exercise_id, tenant)
if _count_exercise_media(cur, exercise_id) >= MAX_EXERCISE_MEDIA:
raise HTTPException(
status_code=400,
detail=f"Maximal {MAX_EXERCISE_MEDIA} Medien pro Übung",
)
cur.execute(
"""SELECT id, mime_type, byte_size, original_filename, visibility, club_id,
uploaded_by_profile_id, storage_key, lifecycle_state
FROM media_assets WHERE id = %s""",
(body.media_asset_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Archiv-Medium nicht gefunden")
asset = r2d(row)
if (asset.get("lifecycle_state") or "").strip().lower() != "active":
raise HTTPException(
status_code=400,
detail="Nur aktive Archiv-Medien können verknüpft werden",
)
if not library_content_visible_to_profile(
cur,
profile_id,
(asset.get("visibility") or "").strip().lower(),
asset.get("club_id"),
asset.get("uploaded_by_profile_id"),
tenant.global_role,
):
raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Archiv-Medium")
cur.execute(
"SELECT 1 FROM exercise_media WHERE exercise_id = %s AND media_asset_id = %s",
(exercise_id, body.media_asset_id),
)
if cur.fetchone():
raise HTTPException(
status_code=400,
detail="Dieses Archiv-Medium ist bereits mit der Übung verknüpft",
)
sk = asset.get("storage_key")
if not sk:
raise HTTPException(status_code=400, detail="Archiv-Eintrag hat keine Datei")
media_type = mt_in or _media_type_from_mime(asset.get("mime_type"))
title = (body.title or "").strip() or None
if title is None and asset.get("original_filename"):
title = str(asset.get("original_filename") or "")[:300] or None
description = (body.description or "").strip() or None
db_path = f"/media/{sk}"
sort_sql = (
"COALESCE((SELECT MAX(sort_order) + 1 FROM exercise_media WHERE exercise_id = %s), 1)"
)
cur.execute(
f"""INSERT INTO exercise_media (
exercise_id, media_type, file_path, file_size, mime_type, original_filename,
embed_url, embed_platform, title, description, context, is_primary, sort_order,
media_asset_id
) VALUES (
%s, %s, %s, %s, %s, %s, NULL, NULL, %s, %s, %s, %s, {sort_sql}, %s
)
RETURNING id, exercise_id, media_type, file_path, file_size, mime_type, original_filename,
embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at,
media_asset_id""",
(
exercise_id,
media_type,
db_path,
asset.get("byte_size"),
asset.get("mime_type"),
asset.get("original_filename"),
title,
description,
ctx,
body.is_primary,
exercise_id,
body.media_asset_id,
),
)
out = r2d(cur.fetchone())
conn.commit()
out["asset_lifecycle_state"] = "active"
return out
@router.put("/exercises/{exercise_id}/media/reorder")
def reorder_exercise_media(
exercise_id: int,

View File

@ -1,21 +1,17 @@
"""Lifecycle für media_assets (Papierkorb) — MEDIA_ASSETS_AND_ARCHIVE_SPEC §5."""
"""Medien-Archiv (Liste, Datei) und Lifecycle — MEDIA_ASSETS_AND_ARCHIVE_SPEC."""
from __future__ import annotations
from typing import Literal
from typing import Any, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from db import get_db, get_cursor
from tenant_context import TenantContext, get_tenant_context
from media_lifecycle import (
assert_can_manage_media_asset_lifecycle,
fetch_media_asset_row,
purge_media_asset,
transition_recover_from_hidden,
transition_to_trash_hidden,
transition_to_trash_soft,
)
from club_tenancy import is_platform_admin, library_content_visible_to_profile
from db import get_db, get_cursor, r2d
from media_lifecycle import fetch_media_asset_row
from media_storage import get_effective_media_root, path_under_media_root
from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible
router = APIRouter(prefix="/api/media-assets", tags=["media-assets"])
@ -24,13 +20,132 @@ class MediaLifecycleBody(BaseModel):
action: Literal["trash_soft", "trash_hidden", "recover", "purge"]
def _fetch_asset_file_row(cur: Any, asset_id: int) -> Optional[dict]:
cur.execute(
"""SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state,
storage_key, mime_type, original_filename
FROM media_assets WHERE id = %s""",
(asset_id,),
)
row = cur.fetchone()
return r2d(row) if row else None
def _assert_can_view_archive_asset(cur: Any, tenant: TenantContext, asset: dict) -> None:
if not library_content_visible_to_profile(
cur,
tenant.profile_id,
(asset.get("visibility") or "").strip().lower(),
asset.get("club_id"),
asset.get("uploaded_by_profile_id"),
tenant.global_role,
):
raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Medium")
@router.get("")
def list_media_assets(
tenant: TenantContext = Depends(get_tenant_context),
q: Optional[str] = Query(None, max_length=120),
limit: int = Query(30, ge=1, le=100),
offset: int = Query(0, ge=0),
):
"""
Durchsuchbares Medien-Archiv: nur aktive Assets, Sichtbarkeit wie Übungsbibliothek.
"""
role = tenant.global_role or ""
is_adm = is_platform_admin(role)
profile_id = tenant.profile_id
needle = (q or "").strip()
params: list[Any] = [is_adm, profile_id, profile_id]
search_sql = ""
if needle:
like = f"%{needle}%"
params.extend([like, like])
search_sql = " AND (ma.original_filename ILIKE %s OR ma.storage_key ILIKE %s)"
params.extend([limit, offset])
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
f"""SELECT ma.id, ma.mime_type, ma.byte_size, ma.original_filename, ma.visibility, ma.club_id,
ma.uploaded_by_profile_id, ma.lifecycle_state, ma.created_at, ma.sha256
FROM media_assets ma
WHERE ma.lifecycle_state = 'active'
AND (
%s
OR lower(trim(ma.visibility)) = 'official'
OR (
lower(trim(ma.visibility)) = 'private'
AND ma.uploaded_by_profile_id = %s
)
OR (
lower(trim(ma.visibility)) = 'club'
AND EXISTS (
SELECT 1 FROM club_members cm
WHERE cm.profile_id = %s
AND cm.club_id = ma.club_id
AND cm.status = 'active'
)
)
)
{search_sql}
ORDER BY ma.created_at DESC
LIMIT %s OFFSET %s""",
params,
)
rows = [r2d(r) for r in cur.fetchall()]
return {"items": rows, "limit": limit, "offset": offset}
@router.api_route("/{asset_id}/file", methods=["GET", "HEAD"])
def download_media_asset_file(
request: Request,
asset_id: int,
tenant: TenantContext = Depends(get_tenant_context_flexible),
):
"""Direktzugriff auf Archiv-Datei (Thumbnail/Vorschau); Auth wie Übungs-Medien (?ssetoken)."""
from routers.exercises import _binary_media_response
with get_db() as conn:
cur = get_cursor(conn)
asset = _fetch_asset_file_row(cur, asset_id)
if not asset:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
lc = (asset.get("lifecycle_state") or "").strip().lower()
if lc != "active":
raise HTTPException(status_code=404, detail="Medium nicht verfügbar")
_assert_can_view_archive_asset(cur, tenant, asset)
sk = asset.get("storage_key")
if not sk:
raise HTTPException(status_code=404, detail="Keine Datei hinterlegt")
media_root = get_effective_media_root(cur)
abs_p = path_under_media_root(media_root, str(sk))
if not abs_p or not abs_p.is_file():
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
mime = asset.get("mime_type") or "application/octet-stream"
fname = asset.get("original_filename") or abs_p.name
return _binary_media_response(abs_p, mime, str(fname) if fname else None, request)
@router.post("/{asset_id}/lifecycle")
def post_media_asset_lifecycle(
asset_id: int,
body: MediaLifecycleBody,
tenant: TenantContext = Depends(get_tenant_context),
):
"""Papierkorb-Übergänge (manuell). Rechte gemäß Spec §5.2."""
"""Papierkorb-Übergänge — media_lifecycle."""
from media_lifecycle import (
assert_can_manage_media_asset_lifecycle,
purge_media_asset,
transition_recover_from_hidden,
transition_to_trash_hidden,
transition_to_trash_soft,
)
with get_db() as conn:
cur = get_cursor(conn)
asset = fetch_media_asset_row(cur, asset_id)

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.42"
APP_VERSION = "0.8.43"
BUILD_DATE = "2026-05-07"
DB_SCHEMA_VERSION = "20260507045"
@ -13,11 +13,11 @@ MODULE_VERSIONS = {
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
"admin_users": "1.0.0", # GET /api/admin/users
"platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT)
"media_assets": "1.0.0", # POST /api/media-assets/{id}/lifecycle (Papierkorb §5)
"media_assets": "1.1.0", # GET Liste + GET …/file (Archiv); POST …/lifecycle
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
"exercises": "2.12.0", # Detail/Liste: asset_lifecycle_state; Download trash_hidden nur für Bearbeiter; Lesen filtert hidden
"exercises": "2.13.0", # POST /media/from-asset (Archiv verknüpfen)
"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,15 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.43",
"date": "2026-05-07",
"changes": [
"Medienarchiv: GET /api/media-assets (Suche, nur aktive Assets, Bibliotheks-Sichtbarkeit); GET /api/media-assets/{id}/file (Thumbnails/Vorschau, ssetoken)",
"Übungen: POST /api/exercises/{id}/media/from-asset — bestehendes Archiv-Medium verknüpfen ohne Upload-Duplikat",
"Frontend Übung bearbeiten: „Aus Archiv verknüpfen…“, Medienvorschau-Modal, Kachel-Thumbnails in der Medienliste",
],
},
{
"version": "0.8.42",
"date": "2026-05-07",

View File

@ -1,6 +1,7 @@
import React, { useEffect, useState, useRef } from 'react'
import React, { useEffect, useState, useRef, useMemo } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import api, { buildExerciseApiPayload } from '../utils/api'
import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
import RichTextEditor from '../components/RichTextEditor'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
@ -369,6 +370,13 @@ function ExerciseFormPage() {
const [embedTitle, setEmbedTitle] = useState('')
const [mediaFields, setMediaFields] = useState({})
const [mediaSavingId, setMediaSavingId] = useState(null)
const [archiveOpen, setArchiveOpen] = useState(false)
const [archiveQ, setArchiveQ] = useState('')
const [archiveCtx, setArchiveCtx] = useState('ablauf')
const [archiveLoading, setArchiveLoading] = useState(false)
const [archiveItems, setArchiveItems] = useState([])
const [archiveError, setArchiveError] = useState(null)
const [mediaPreview, setMediaPreview] = useState(null)
useEffect(() => {
const next = {}
@ -381,6 +389,30 @@ function ExerciseFormPage() {
setMediaFields(next)
}, [mediaList])
useEffect(() => {
if (!archiveOpen) return undefined
let cancelled = false
const t = setTimeout(async () => {
setArchiveLoading(true)
setArchiveError(null)
try {
const res = await api.listMediaAssets({
q: archiveQ.trim() || undefined,
limit: 40,
})
if (!cancelled) setArchiveItems(res.items || [])
} catch (e) {
if (!cancelled) setArchiveError(e.message || String(e))
} finally {
if (!cancelled) setArchiveLoading(false)
}
}, 280)
return () => {
cancelled = true
clearTimeout(t)
}
}, [archiveOpen, archiveQ])
useEffect(() => {
let cancelled = false
const boot = async () => {
@ -558,6 +590,28 @@ function ExerciseFormPage() {
setMediaList(ex.media || [])
}
const attachFromArchive = async (assetId) => {
if (!exerciseId) return
try {
await api.attachExerciseMediaFromAsset(exerciseId, {
media_asset_id: assetId,
context: archiveCtx,
title: '',
description: '',
is_primary: false,
})
setArchiveOpen(false)
await refreshMedia()
} catch (e) {
alert(e.message || String(e))
}
}
const linkedArchiveAssetIds = useMemo(
() => new Set((mediaList || []).map((m) => m.media_asset_id).filter(Boolean)),
[mediaList],
)
const handleUploadFile = async () => {
if (!exerciseId || !mediaFile) {
alert('Datei wählen')
@ -1228,8 +1282,25 @@ 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.
Datei oder Embed (YouTube, Vimeo, Instagram, TikTok). Max. 10 pro Übung. Archiv-Medien können mehrfach
verknüpft werden.
</p>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
alignItems: 'center',
marginTop: '10px',
}}
>
<button type="button" className="btn btn-secondary" onClick={() => setArchiveOpen(true)}>
Aus Archiv verknüpfen
</button>
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
Sichtbare, bereits gespeicherte Dateien erneut nutzen (ein Speicherplatz, mehrere Übungen).
</span>
</div>
<div style={{ display: 'grid', gap: '12px', marginTop: '12px' }}>
<div>
<label className="form-label">Datei</label>
@ -1299,8 +1370,49 @@ function ExerciseFormPage() {
marginBottom: '10px',
padding: '10px 12px',
border: '1px solid var(--border)',
display: 'flex',
gap: '12px',
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>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
#{idx + 1} · {m.media_type}
@ -1463,10 +1575,198 @@ function ExerciseFormPage() {
>
Löschen
</button>
</div>
</li>
))}
</ul>
)}
{archiveOpen && (
<div
role="dialog"
aria-modal="true"
aria-label="Medienarchiv"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
zIndex: 1000,
overflow: 'auto',
padding: '16px',
}}
onClick={() => setArchiveOpen(false)}
onKeyDown={(e) => e.key === 'Escape' && setArchiveOpen(false)}
>
<div
className="card"
style={{
maxWidth: 560,
margin: '4vh auto',
maxHeight: '88vh',
overflow: 'auto',
position: 'relative',
}}
onClick={(e) => e.stopPropagation()}
>
<h3 style={{ marginTop: 0, fontSize: '1.05rem' }}>Medienarchiv</h3>
<p style={{ fontSize: '13px', color: 'var(--text2)', marginTop: 0 }}>
Nur aktive Medien, die Sie lesen dürfen (offiziell, eigenes privat, Ihre Vereine).
</p>
<input
type="search"
className="form-input"
placeholder="Suche Dateiname…"
value={archiveQ}
onChange={(e) => setArchiveQ(e.target.value)}
style={{ marginBottom: '8px' }}
/>
<select
className="form-input"
value={archiveCtx}
onChange={(e) => setArchiveCtx(e.target.value)}
style={{ marginBottom: '12px' }}
>
<option value="ablauf">Sektion: Ablauf</option>
<option value="detail">Sektion: Detail</option>
<option value="trainer_hint">Sektion: Trainer-Hinweis</option>
</select>
{archiveLoading && <p style={{ fontSize: '13px', color: 'var(--text3)' }}>Laden</p>}
{archiveError && <p style={{ fontSize: '13px', color: 'var(--danger)' }}>{archiveError}</p>}
{!archiveLoading && !archiveError && archiveItems.length === 0 && (
<p style={{ fontSize: '13px', color: 'var(--text3)' }}>Keine Treffer.</p>
)}
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{archiveItems.map((a) => {
const already = linkedArchiveAssetIds.has(a.id)
return (
<li
key={a.id}
style={{
display: 'flex',
gap: '10px',
alignItems: 'center',
padding: '8px 0',
borderBottom: '1px solid var(--border)',
}}
>
<div
style={{
width: 56,
height: 56,
flexShrink: 0,
borderRadius: '6px',
overflow: 'hidden',
background: 'var(--surface2, rgba(127,127,127,0.12))',
border: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{a.mime_type?.startsWith('image/') ? (
<img
alt=""
src={resolveMediaAssetFileUrl(a.id)}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : a.mime_type?.startsWith('video/') ? (
<span style={{ fontSize: '18px', opacity: 0.75 }} aria-hidden>
</span>
) : (
<span style={{ fontSize: '10px', color: 'var(--text2)', padding: '2px', textAlign: 'center' }}>
PDF
</span>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: '13px', fontWeight: 600, wordBreak: 'break-word' }}>
{a.original_filename || `Asset #${a.id}`}
</div>
<div style={{ fontSize: '11px', color: 'var(--text3)', marginTop: '2px' }}>
{a.visibility} · {a.mime_type || '—'}{' '}
{a.byte_size != null ? `· ${(a.byte_size / 1024).toFixed(0)} KB` : ''}
</div>
</div>
<button
type="button"
className="btn btn-primary"
style={{ fontSize: '12px' }}
disabled={already}
title={already ? 'Schon mit dieser Übung verknüpft' : ''}
onClick={() => !already && attachFromArchive(a.id)}
>
{already ? 'Bereits verknüpft' : 'Verknüpfen'}
</button>
</li>
)
})}
</ul>
<div style={{ marginTop: '16px', display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button type="button" className="btn btn-secondary" onClick={() => setArchiveOpen(false)}>
Schließen
</button>
</div>
</div>
</div>
)}
{mediaPreview && (
<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={() => setMediaPreview(null)}
onKeyDown={(e) => e.key === 'Escape' && setMediaPreview(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>
{mediaPreview.embed_url ? (
<p style={{ fontSize: '14px', wordBreak: 'break-all' }}>
<a href={mediaPreview.embed_url} target="_blank" rel="noreferrer">
{mediaPreview.embed_url}
</a>
</p>
) : mediaPreview.mime_type?.startsWith('video/') ? (
<video
src={resolveExerciseMediaFileUrl(exerciseId, mediaPreview)}
controls
style={{ width: '100%', borderRadius: '8px', maxHeight: '70vh' }}
/>
) : mediaPreview.mime_type?.startsWith('image/') ? (
<img
alt=""
src={resolveExerciseMediaFileUrl(exerciseId, mediaPreview)}
style={{ maxWidth: '100%', borderRadius: '8px', maxHeight: '70vh', objectFit: 'contain' }}
/>
) : (
<p style={{ fontSize: '14px' }}>
<a href={resolveExerciseMediaFileUrl(exerciseId, mediaPreview)} target="_blank" rel="noreferrer">
Datei öffnen
</a>
</p>
)}
<div style={{ marginTop: '16px' }}>
<button type="button" className="btn btn-secondary" onClick={() => setMediaPreview(null)}>
Schließen
</button>
</div>
</div>
</div>
)}
</div>
)}

View File

@ -510,6 +510,24 @@ export async function postMediaAssetLifecycle(assetId, action) {
})
}
/** Archiv: aktive media_assets sichtbar für den Nutzer (Bibliotheksrechte). */
export async function listMediaAssets(params = {}) {
const sp = new URLSearchParams()
if (params.q) sp.set('q', params.q)
if (params.limit != null) sp.set('limit', String(params.limit))
if (params.offset != null) sp.set('offset', String(params.offset))
const qs = sp.toString()
return request(`/api/media-assets${qs ? `?${qs}` : ''}`)
}
/** Übung: bestehendes Archiv-Medium verknüpfen (`POST /api/exercises/{id}/media/from-asset`). */
export async function attachExerciseMediaFromAsset(exerciseId, body) {
return request(`/api/exercises/${exerciseId}/media/from-asset`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export async function getExercise(id) {
return request(`/api/exercises/${id}`)
}
@ -1212,6 +1230,8 @@ export const api = {
deleteExerciseMedia,
reorderExerciseMedia,
postMediaAssetLifecycle,
listMediaAssets,
attachExerciseMediaFromAsset,
listExerciseProgressionGraphs,
getExerciseProgressionGraph,
createExerciseProgressionGraph,

View File

@ -17,3 +17,15 @@ export function resolveExerciseMediaFileUrl(exerciseId, media) {
if (id == null || exerciseId == null) return null
return `${API_BASE}/api/exercises/${exerciseId}/media/${id}/file${q}`
}
/**
* Direkt-URL für Archiv-Asset (Picker/Vorschau ohne exercise_media-Zeile).
* @param {number|string} assetId media_assets.id
* @returns {string|null}
*/
export function resolveMediaAssetFileUrl(assetId) {
if (assetId == null) return null
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('authToken') : ''
const q = token ? `?ssetoken=${encodeURIComponent(token)}` : ''
return `${API_BASE}/api/media-assets/${assetId}/file${q}`
}