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
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:
parent
8ac723eafe
commit
631ba1cb43
|
|
@ -19,6 +19,9 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
|||
| 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 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 — `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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user