Medienmanager und Sicherheitsupdate #21
|
|
@ -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` |
|
| 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 |
|
| 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 | `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 |
|
| 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 |
|
| 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 |
|
| 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.
|
**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]
|
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):
|
class ExerciseVariantCreate(BaseModel):
|
||||||
variant_name: str = Field(..., min_length=3, max_length=200)
|
variant_name: str = Field(..., min_length=3, max_length=200)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
|
@ -302,6 +313,17 @@ def _detect_embed_platform(url: str) -> Optional[str]:
|
||||||
return None
|
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]:
|
def _fetch_exercise_governance_row(cur, exercise_id: int) -> Optional[dict]:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT id, visibility, club_id, created_by FROM exercises WHERE id = %s",
|
"SELECT id, visibility, club_id, created_by FROM exercises WHERE id = %s",
|
||||||
|
|
@ -2222,6 +2244,115 @@ async def upload_exercise_media(
|
||||||
return r2d(row)
|
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")
|
@router.put("/exercises/{exercise_id}/media/reorder")
|
||||||
def reorder_exercise_media(
|
def reorder_exercise_media(
|
||||||
exercise_id: int,
|
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 __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 pydantic import BaseModel
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
from club_tenancy import is_platform_admin, library_content_visible_to_profile
|
||||||
from tenant_context import TenantContext, get_tenant_context
|
from db import get_db, get_cursor, r2d
|
||||||
from media_lifecycle import (
|
from media_lifecycle import fetch_media_asset_row
|
||||||
assert_can_manage_media_asset_lifecycle,
|
from media_storage import get_effective_media_root, path_under_media_root
|
||||||
fetch_media_asset_row,
|
from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible
|
||||||
purge_media_asset,
|
|
||||||
transition_recover_from_hidden,
|
|
||||||
transition_to_trash_hidden,
|
|
||||||
transition_to_trash_soft,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/media-assets", tags=["media-assets"])
|
router = APIRouter(prefix="/api/media-assets", tags=["media-assets"])
|
||||||
|
|
||||||
|
|
@ -24,13 +20,132 @@ class MediaLifecycleBody(BaseModel):
|
||||||
action: Literal["trash_soft", "trash_hidden", "recover", "purge"]
|
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")
|
@router.post("/{asset_id}/lifecycle")
|
||||||
def post_media_asset_lifecycle(
|
def post_media_asset_lifecycle(
|
||||||
asset_id: int,
|
asset_id: int,
|
||||||
body: MediaLifecycleBody,
|
body: MediaLifecycleBody,
|
||||||
tenant: TenantContext = Depends(get_tenant_context),
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
asset = fetch_media_asset_row(cur, asset_id)
|
asset = fetch_media_asset_row(cur, asset_id)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.42"
|
APP_VERSION = "0.8.43"
|
||||||
BUILD_DATE = "2026-05-07"
|
BUILD_DATE = "2026-05-07"
|
||||||
DB_SCHEMA_VERSION = "20260507045"
|
DB_SCHEMA_VERSION = "20260507045"
|
||||||
|
|
||||||
|
|
@ -13,11 +13,11 @@ MODULE_VERSIONS = {
|
||||||
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
|
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
|
||||||
"admin_users": "1.0.0", # GET /api/admin/users
|
"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)
|
"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",
|
"groups": "0.1.0",
|
||||||
"skills": "0.1.0",
|
"skills": "0.1.0",
|
||||||
"methods": "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_units": "0.2.0",
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
|
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
|
||||||
|
|
@ -29,6 +29,15 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.42",
|
||||||
"date": "2026-05-07",
|
"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 { useNavigate, useParams } from 'react-router-dom'
|
||||||
import api, { buildExerciseApiPayload } from '../utils/api'
|
import api, { buildExerciseApiPayload } from '../utils/api'
|
||||||
|
import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
|
||||||
import RichTextEditor from '../components/RichTextEditor'
|
import RichTextEditor from '../components/RichTextEditor'
|
||||||
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
|
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
|
||||||
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
|
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
|
||||||
|
|
@ -369,6 +370,13 @@ function ExerciseFormPage() {
|
||||||
const [embedTitle, setEmbedTitle] = useState('')
|
const [embedTitle, setEmbedTitle] = useState('')
|
||||||
const [mediaFields, setMediaFields] = useState({})
|
const [mediaFields, setMediaFields] = useState({})
|
||||||
const [mediaSavingId, setMediaSavingId] = useState(null)
|
const [mediaSavingId, setMediaSavingId] = useState(null)
|
||||||
|
const [archiveOpen, setArchiveOpen] = useState(false)
|
||||||
|
const [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(() => {
|
useEffect(() => {
|
||||||
const next = {}
|
const next = {}
|
||||||
|
|
@ -381,6 +389,30 @@ function ExerciseFormPage() {
|
||||||
setMediaFields(next)
|
setMediaFields(next)
|
||||||
}, [mediaList])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const boot = async () => {
|
const boot = async () => {
|
||||||
|
|
@ -558,6 +590,28 @@ function ExerciseFormPage() {
|
||||||
setMediaList(ex.media || [])
|
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 () => {
|
const handleUploadFile = async () => {
|
||||||
if (!exerciseId || !mediaFile) {
|
if (!exerciseId || !mediaFile) {
|
||||||
alert('Datei wählen')
|
alert('Datei wählen')
|
||||||
|
|
@ -1228,8 +1282,25 @@ function ExerciseFormPage() {
|
||||||
<div className="card" style={{ marginTop: '16px' }}>
|
<div className="card" style={{ marginTop: '16px' }}>
|
||||||
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Medien</h2>
|
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Medien</h2>
|
||||||
<p style={{ color: 'var(--text2)', fontSize: '13px' }}>
|
<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>
|
</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 style={{ display: 'grid', gap: '12px', marginTop: '12px' }}>
|
||||||
<div>
|
<div>
|
||||||
<label className="form-label">Datei</label>
|
<label className="form-label">Datei</label>
|
||||||
|
|
@ -1299,8 +1370,49 @@ function ExerciseFormPage() {
|
||||||
marginBottom: '10px',
|
marginBottom: '10px',
|
||||||
padding: '10px 12px',
|
padding: '10px 12px',
|
||||||
border: '1px solid var(--border)',
|
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' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
|
||||||
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
|
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
|
||||||
#{idx + 1} · {m.media_type}
|
#{idx + 1} · {m.media_type}
|
||||||
|
|
@ -1463,10 +1575,198 @@ function ExerciseFormPage() {
|
||||||
>
|
>
|
||||||
Löschen
|
Löschen
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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>
|
</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) {
|
export async function getExercise(id) {
|
||||||
return request(`/api/exercises/${id}`)
|
return request(`/api/exercises/${id}`)
|
||||||
}
|
}
|
||||||
|
|
@ -1212,6 +1230,8 @@ export const api = {
|
||||||
deleteExerciseMedia,
|
deleteExerciseMedia,
|
||||||
reorderExerciseMedia,
|
reorderExerciseMedia,
|
||||||
postMediaAssetLifecycle,
|
postMediaAssetLifecycle,
|
||||||
|
listMediaAssets,
|
||||||
|
attachExerciseMediaFromAsset,
|
||||||
listExerciseProgressionGraphs,
|
listExerciseProgressionGraphs,
|
||||||
getExerciseProgressionGraph,
|
getExerciseProgressionGraph,
|
||||||
createExerciseProgressionGraph,
|
createExerciseProgressionGraph,
|
||||||
|
|
|
||||||
|
|
@ -17,3 +17,15 @@ export function resolveExerciseMediaFileUrl(exerciseId, media) {
|
||||||
if (id == null || exerciseId == null) return null
|
if (id == null || exerciseId == null) return null
|
||||||
return `${API_BASE}/api/exercises/${exerciseId}/media/${id}/file${q}`
|
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