feat: implement bulk upload functionality for media assets
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 24s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 29s
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 24s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 29s
- Added a new API endpoint for bulk uploading media assets, allowing users to upload multiple files in a single request. - Implemented validation for file types and sizes during the upload process, ensuring compliance with allowed formats and limits. - Enhanced the MediaLibraryPage component to support bulk file selection and visibility options, improving user experience. - Updated CSS styles for the upload interface to enhance layout and accessibility. - Added tests to verify the functionality of the new bulk upload feature and its integration with existing media asset management.
This commit is contained in:
parent
4f64cb105b
commit
f9e6e61244
|
|
@ -3,7 +3,10 @@ from __future__ import annotations
|
|||
|
||||
from typing import Any, Literal, Optional, Union
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from club_tenancy import (
|
||||
|
|
@ -33,6 +36,8 @@ from media_lifecycle import (
|
|||
from media_storage import get_effective_media_root, path_under_media_root
|
||||
from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible
|
||||
|
||||
from routers.exercises import ALLOWED_UPLOAD_MIMES, _upload_limit_bytes
|
||||
|
||||
router = APIRouter(prefix="/api/media-assets", tags=["media-assets"])
|
||||
|
||||
|
||||
|
|
@ -445,6 +450,190 @@ def _apply_lifecycle_action(
|
|||
raise HTTPException(status_code=500, detail="Interner Fehler: lifecycle action")
|
||||
|
||||
|
||||
_MAX_BULK_LIBRARY_FILES = 25
|
||||
|
||||
|
||||
def _ingest_library_media_file(
|
||||
cur: Any,
|
||||
tenant: TenantContext,
|
||||
raw: bytes,
|
||||
filename: Optional[str],
|
||||
content_type: Optional[str],
|
||||
visibility: str,
|
||||
club_id_form: Optional[int],
|
||||
) -> dict:
|
||||
"""Neues Archiv-Medium oder aktiver Dedupe-Treffer (sha256 + Sichtbarkeit + Verein). Kein exercise_media."""
|
||||
profile_id = tenant.profile_id
|
||||
role = tenant.global_role or ""
|
||||
vis = (visibility or "private").strip().lower()
|
||||
if vis not in ("private", "club", "official"):
|
||||
raise HTTPException(status_code=400, detail="Ungültige Sichtbarkeit")
|
||||
|
||||
next_cid: Optional[int] = None
|
||||
if vis == "club":
|
||||
if club_id_form is None:
|
||||
raise HTTPException(status_code=400, detail="Verein erforderlich für Sichtbarkeit „Verein“")
|
||||
next_cid = int(club_id_form)
|
||||
|
||||
assert_valid_governance_visibility(cur, profile_id, role, vis, next_cid)
|
||||
|
||||
max_b = _upload_limit_bytes(tenant)
|
||||
if len(raw) > max_b:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"Datei zu groß (max. {max_b // (1024 * 1024)} MB)",
|
||||
)
|
||||
|
||||
mime = (content_type or "").split(";")[0].strip().lower()
|
||||
if mime not in ALLOWED_UPLOAD_MIMES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Dateityp nicht erlaubt: {mime or 'unbekannt'}",
|
||||
)
|
||||
|
||||
full_sha = hashlib.sha256(raw).hexdigest()
|
||||
cur.execute(
|
||||
"""SELECT id, storage_key, byte_size, lifecycle_state, original_filename FROM media_assets
|
||||
WHERE sha256 = %s AND lower(trim(visibility)) = %s
|
||||
AND (club_id IS NOT DISTINCT FROM %s)
|
||||
LIMIT 1""",
|
||||
(full_sha, vis, next_cid),
|
||||
)
|
||||
existing_asset = cur.fetchone()
|
||||
|
||||
if existing_asset:
|
||||
ea = r2d(existing_asset)
|
||||
lc = (ea.get("lifecycle_state") or "").strip().lower()
|
||||
if lc == "active":
|
||||
return {
|
||||
"status": "duplicate",
|
||||
"media_asset_id": int(ea["id"]),
|
||||
"original_filename": ea.get("original_filename"),
|
||||
}
|
||||
if lc in ("trash_soft", "trash_hidden"):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"code": "MEDIA_ASSET_IN_TRASH",
|
||||
"message": (
|
||||
"Diese Datei ist inhaltsgleich (SHA-256) mit einem Archiv-Medium im Papierkorb."
|
||||
),
|
||||
"media_asset_id": ea["id"],
|
||||
"lifecycle_state": lc,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Es existiert bereits ein Archiv-Eintrag zu dieser Datei in einem nicht nutzbaren Zustand.",
|
||||
)
|
||||
|
||||
ext = Path(filename or "").suffix[:12] if filename else ""
|
||||
if not ext and mime == "image/jpeg":
|
||||
ext = ".jpg"
|
||||
elif not ext and mime == "image/png":
|
||||
ext = ".png"
|
||||
|
||||
media_root = get_effective_media_root(cur)
|
||||
storage_key = f"exercises/{full_sha}{ext}"
|
||||
dest_path = path_under_media_root(media_root, storage_key)
|
||||
if dest_path is None:
|
||||
raise HTTPException(status_code=500, detail="Ungültiger Speicherpfad")
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not dest_path.is_file():
|
||||
dest_path.write_bytes(raw)
|
||||
|
||||
cur.execute(
|
||||
"""INSERT INTO media_assets (
|
||||
mime_type, byte_size, sha256, original_filename, visibility, club_id,
|
||||
uploaded_by_profile_id, copyright_notice, storage_backend, storage_key, lifecycle_state
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, NULL, 'local', %s, 'active')
|
||||
RETURNING id""",
|
||||
(
|
||||
mime,
|
||||
len(raw),
|
||||
full_sha,
|
||||
filename or storage_key,
|
||||
vis,
|
||||
next_cid,
|
||||
profile_id,
|
||||
storage_key,
|
||||
),
|
||||
)
|
||||
ar = cur.fetchone()
|
||||
aid = int(r2d(ar)["id"])
|
||||
return {"status": "created", "media_asset_id": aid, "original_filename": filename or storage_key}
|
||||
|
||||
|
||||
@router.post("/bulk-upload")
|
||||
async def bulk_upload_media_assets(
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
files: list[UploadFile] = File(..., description="Mehrere Dateien (jpeg, png, gif, mp4, pdf)"),
|
||||
visibility: str = Form("private"),
|
||||
club_id: Optional[int] = Form(None),
|
||||
):
|
||||
"""Mehrere Dateien ins Archiv; Dedupe wie Übungs-Upload. Pro Datei eigene DB-Transaktion."""
|
||||
if not files:
|
||||
raise HTTPException(status_code=400, detail="Keine Dateien übermittelt")
|
||||
if len(files) > _MAX_BULK_LIBRARY_FILES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Maximal {_MAX_BULK_LIBRARY_FILES} Dateien pro Anfrage",
|
||||
)
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
created = duplicate = failed = 0
|
||||
|
||||
for uf in files:
|
||||
fn = uf.filename or "ohne_name"
|
||||
try:
|
||||
raw = await uf.read()
|
||||
if not raw:
|
||||
results.append({"filename": fn, "ok": False, "detail": "Leere Datei"})
|
||||
failed += 1
|
||||
continue
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
r = _ingest_library_media_file(
|
||||
cur,
|
||||
tenant,
|
||||
raw,
|
||||
uf.filename,
|
||||
uf.content_type,
|
||||
visibility,
|
||||
club_id,
|
||||
)
|
||||
results.append({"filename": fn, "ok": True, **r})
|
||||
if r["status"] == "created":
|
||||
created += 1
|
||||
else:
|
||||
duplicate += 1
|
||||
except HTTPException as e:
|
||||
detail = e.detail
|
||||
if isinstance(detail, dict):
|
||||
detail_s = detail.get("message") or detail.get("code") or str(detail)
|
||||
else:
|
||||
detail_s = str(detail)
|
||||
results.append(
|
||||
{
|
||||
"filename": fn,
|
||||
"ok": False,
|
||||
"status_code": e.status_code,
|
||||
"detail": detail_s,
|
||||
},
|
||||
)
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
results.append({"filename": fn, "ok": False, "detail": str(e)})
|
||||
failed += 1
|
||||
|
||||
return {
|
||||
"results": results,
|
||||
"created_count": created,
|
||||
"duplicate_count": duplicate,
|
||||
"failed_count": failed,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_media_assets(
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
|
|
@ -489,21 +678,22 @@ def list_media_assets(
|
|||
|
||||
media_kind_sql = ""
|
||||
if mk == "image":
|
||||
media_kind_sql = " AND lower(COALESCE(ma.mime_type, '')) LIKE 'image/%'"
|
||||
# %% für psycopg2: sonst wird % als Platzhalter-Syntax interpretiert
|
||||
media_kind_sql = " AND lower(COALESCE(ma.mime_type, '')) LIKE 'image/%%'"
|
||||
elif mk == "video":
|
||||
media_kind_sql = " AND lower(COALESCE(ma.mime_type, '')) LIKE 'video/%'"
|
||||
media_kind_sql = " AND lower(COALESCE(ma.mime_type, '')) LIKE 'video/%%'"
|
||||
elif mk == "pdf":
|
||||
media_kind_sql = (
|
||||
" AND (lower(COALESCE(ma.mime_type, '')) = 'application/pdf'"
|
||||
" OR lower(COALESCE(ma.mime_type, '')) LIKE '%pdf%')"
|
||||
" OR lower(COALESCE(ma.mime_type, '')) LIKE '%%pdf%%')"
|
||||
)
|
||||
elif mk == "other":
|
||||
media_kind_sql = (
|
||||
" AND COALESCE(ma.mime_type, '') <> ''"
|
||||
" AND lower(ma.mime_type) NOT LIKE 'image/%'"
|
||||
" AND lower(ma.mime_type) NOT LIKE 'video/%'"
|
||||
" AND lower(ma.mime_type) NOT LIKE 'image/%%'"
|
||||
" AND lower(ma.mime_type) NOT LIKE 'video/%%'"
|
||||
" AND lower(ma.mime_type) <> 'application/pdf'"
|
||||
" AND lower(ma.mime_type) NOT LIKE '%pdf%'"
|
||||
" AND lower(ma.mime_type) NOT LIKE '%%pdf%%'"
|
||||
)
|
||||
|
||||
club_sql = ""
|
||||
|
|
|
|||
|
|
@ -87,6 +87,32 @@ def test_list_media_assets_ok_mocked(client: TestClient) -> None:
|
|||
assert "viewer" in body
|
||||
|
||||
|
||||
def test_list_media_assets_media_kind_image_ok_mocked(client: TestClient) -> None:
|
||||
"""media_kind=image setzt %% in SQL — Regression gegen psycopg2-%-Platzhalter."""
|
||||
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
|
||||
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
|
||||
profile_id=10,
|
||||
global_role="trainer",
|
||||
effective_club_id=5,
|
||||
club_ids=frozenset({5}),
|
||||
memberships=[],
|
||||
)
|
||||
|
||||
mock_cur = MagicMock()
|
||||
mock_cur.fetchall.side_effect = [[], []]
|
||||
mock_cm = _mock_db(mock_cur)
|
||||
|
||||
with patch("routers.media_assets.get_db", return_value=mock_cm), patch(
|
||||
"routers.media_assets.get_cursor", return_value=mock_cur
|
||||
), patch("routers.media_assets.club_ids_for_profile_with_roles", return_value=set()):
|
||||
r = client.get("/api/media-assets?media_kind=image", headers={"X-Auth-Token": "t"})
|
||||
|
||||
assert r.status_code == 200
|
||||
calls = [str(c) for c in mock_cur.execute.call_args_list]
|
||||
joined = " ".join(calls)
|
||||
assert "image/%%" in joined
|
||||
|
||||
|
||||
def test_attach_from_asset_duplicate_returns_400(client: TestClient) -> None:
|
||||
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
|
||||
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
|
||||
|
|
|
|||
|
|
@ -5546,6 +5546,61 @@ a.analysis-split__nav-item {
|
|||
flex: 1 1 140px;
|
||||
max-width: 240px;
|
||||
}
|
||||
.media-library__upload-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.media-library__upload-row .form-input {
|
||||
min-width: 0;
|
||||
flex: 0 1 160px;
|
||||
max-width: 220px;
|
||||
}
|
||||
.media-library__upload-summary {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text2);
|
||||
flex: 1 1 200px;
|
||||
}
|
||||
.media-library__upload-icon {
|
||||
vertical-align: middle;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.media-library__sr-file {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.media-library__card-type {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 6px;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: var(--text2);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-library__card-type--compact {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
left: 3px;
|
||||
top: 3px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.media-library__card-copyright {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
|
|
@ -5797,6 +5852,7 @@ a.analysis-split__nav-item {
|
|||
width: 72px;
|
||||
}
|
||||
.media-library__table-thumb {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
LayoutGrid,
|
||||
|
|
@ -13,6 +13,11 @@ import {
|
|||
CircleDot,
|
||||
FilePenLine,
|
||||
Copyright,
|
||||
Image,
|
||||
Video,
|
||||
FileText,
|
||||
File,
|
||||
Upload,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../utils/api'
|
||||
|
|
@ -195,6 +200,32 @@ function previewDisplayKind(mimeType) {
|
|||
return 'other'
|
||||
}
|
||||
|
||||
const MEDIA_KIND_LABELS = {
|
||||
image: 'Bild',
|
||||
video: 'Video',
|
||||
pdf: 'PDF',
|
||||
other: 'Sonstiges',
|
||||
}
|
||||
|
||||
function MediaTypeGlyph({ mimeType, compact }) {
|
||||
const kind = previewDisplayKind(mimeType)
|
||||
const label = MEDIA_KIND_LABELS[kind] || 'Medium'
|
||||
let Icon = File
|
||||
if (kind === 'image') Icon = Image
|
||||
else if (kind === 'video') Icon = Video
|
||||
else if (kind === 'pdf') Icon = FileText
|
||||
const sz = compact ? 12 : 14
|
||||
return (
|
||||
<span
|
||||
className={`media-library__card-type${compact ? ' media-library__card-type--compact' : ''}`}
|
||||
title={label}
|
||||
aria-label={`Medientyp: ${label}`}
|
||||
>
|
||||
<Icon size={sz} strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MediaLibraryPage() {
|
||||
const { user } = useAuth()
|
||||
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
||||
|
|
@ -222,6 +253,11 @@ export default function MediaLibraryPage() {
|
|||
const [filterClubId, setFilterClubId] = useState('')
|
||||
const [filterUploaderId, setFilterUploaderId] = useState('')
|
||||
const [uploaderFilterOptions, setUploaderFilterOptions] = useState([])
|
||||
const bulkFileInputRef = useRef(null)
|
||||
const [uploadVis, setUploadVis] = useState('private')
|
||||
const [uploadClubId, setUploadClubId] = useState('')
|
||||
const [uploadBusy, setUploadBusy] = useState(false)
|
||||
const [uploadSummary, setUploadSummary] = useState('')
|
||||
|
||||
const loadClubs = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -428,6 +464,33 @@ export default function MediaLibraryPage() {
|
|||
|
||||
const selCount = selected.size
|
||||
|
||||
const onBulkArchiveFiles = async (e) => {
|
||||
const fl = e.target.files
|
||||
if (!fl?.length) return
|
||||
const list = Array.from(fl)
|
||||
e.target.value = ''
|
||||
if (uploadVis === 'club' && !Number(uploadClubId)) {
|
||||
window.alert('Bitte einen Verein für die Sichtbarkeit „Verein“ wählen.')
|
||||
return
|
||||
}
|
||||
setUploadBusy(true)
|
||||
setUploadSummary('')
|
||||
try {
|
||||
const res = await api.bulkUploadMediaAssets(list, {
|
||||
visibility: uploadVis,
|
||||
...(uploadVis === 'club' ? { club_id: Number(uploadClubId) } : {}),
|
||||
})
|
||||
setUploadSummary(
|
||||
`Archiv-Upload: neu ${res.created_count}, bereits vorhanden ${res.duplicate_count}, fehlgeschlagen ${res.failed_count}.`,
|
||||
)
|
||||
await loadItems()
|
||||
} catch (err) {
|
||||
window.alert(err.message || String(err))
|
||||
} finally {
|
||||
setUploadBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-page media-library">
|
||||
{isPlatformAdmin ? <AdminPageNav /> : null}
|
||||
|
|
@ -541,6 +604,61 @@ export default function MediaLibraryPage() {
|
|||
</select>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="media-library__upload-row" aria-label="Archiv hochladen">
|
||||
<input
|
||||
ref={bulkFileInputRef}
|
||||
type="file"
|
||||
className="media-library__sr-file"
|
||||
accept="image/jpeg,image/png,image/gif,video/mp4,application/pdf,.jpg,.jpeg,.png,.gif,.mp4,.pdf"
|
||||
multiple
|
||||
onChange={onBulkArchiveFiles}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={uploadBusy}
|
||||
onClick={() => bulkFileInputRef.current?.click()}
|
||||
title="Mehrere Dateien ins Archiv laden"
|
||||
>
|
||||
<Upload size={18} aria-hidden className="media-library__upload-icon" />
|
||||
Archiv-Upload
|
||||
</button>
|
||||
<select
|
||||
className="form-input"
|
||||
value={uploadVis}
|
||||
onChange={(e) => {
|
||||
setUploadVis(e.target.value)
|
||||
setUploadSummary('')
|
||||
}}
|
||||
aria-label="Sichtbarkeit für neuen Upload"
|
||||
>
|
||||
{VIS_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
Upload: {o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{uploadVis === 'club' ? (
|
||||
<select
|
||||
className="form-input"
|
||||
value={uploadClubId}
|
||||
onChange={(e) => setUploadClubId(e.target.value)}
|
||||
aria-label="Verein für Upload"
|
||||
>
|
||||
<option value="">Verein wählen…</option>
|
||||
{clubs.map((c) => (
|
||||
<option key={c.id} value={String(c.id)}>
|
||||
{c.name || c.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
{uploadSummary ? (
|
||||
<span className="media-library__upload-summary" role="status">
|
||||
{uploadSummary}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="media-library__toolbar-meta">
|
||||
<label className="media-library__check-all">
|
||||
<input type="checkbox" checked={items.length > 0 && selCount === items.length} onChange={selectAll} />
|
||||
|
|
@ -591,6 +709,7 @@ export default function MediaLibraryPage() {
|
|||
title="Vorschau"
|
||||
>
|
||||
<div className="media-library__card-thumb-wrap">
|
||||
<MediaTypeGlyph mimeType={it.mime_type} />
|
||||
<MediaThumb mediaId={it.id} mimeType={it.mime_type} />
|
||||
{(it.copyright_notice || '').trim() ? (
|
||||
<span
|
||||
|
|
@ -658,6 +777,7 @@ export default function MediaLibraryPage() {
|
|||
title="Vorschau"
|
||||
>
|
||||
<div className="media-library__table-thumb">
|
||||
<MediaTypeGlyph mimeType={it.mime_type} compact />
|
||||
<MediaThumb mediaId={it.id} mimeType={it.mime_type} />
|
||||
</div>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -564,6 +564,49 @@ export async function bulkPatchMediaAssets(data) {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mehrere Dateien ins Medienarchiv (`POST /api/media-assets/bulk-upload`).
|
||||
* @param {File[]} files
|
||||
* @param {{ visibility?: string, club_id?: number }} [options]
|
||||
*/
|
||||
export async function bulkUploadMediaAssets(files, options = {}) {
|
||||
const visibility = options.visibility || 'private'
|
||||
const token = localStorage.getItem('authToken')
|
||||
const headers = mergeActiveClubHeader({})
|
||||
if (token) headers['X-Auth-Token'] = token
|
||||
const formData = new FormData()
|
||||
formData.append('visibility', String(visibility))
|
||||
if (options.club_id != null && options.club_id !== '') {
|
||||
formData.append('club_id', String(options.club_id))
|
||||
}
|
||||
const arr = Array.isArray(files) ? files : [files]
|
||||
for (const f of arr) {
|
||||
if (f) formData.append('files', f)
|
||||
}
|
||||
const url = `${API_URL}/api/media-assets/bulk-upload`
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
})
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
let parsed = null
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
parsed = null
|
||||
}
|
||||
if (parsed?.detail != null) {
|
||||
const d = parsed.detail
|
||||
throw new Error(typeof d === 'string' ? d : JSON.stringify(d))
|
||||
}
|
||||
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
|
||||
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/** Ü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`, {
|
||||
|
|
@ -1313,6 +1356,7 @@ export const api = {
|
|||
patchMediaAsset,
|
||||
bulkMediaLifecycle,
|
||||
bulkPatchMediaAssets,
|
||||
bulkUploadMediaAssets,
|
||||
attachExerciseMediaFromAsset,
|
||||
listExerciseProgressionGraphs,
|
||||
getExerciseProgressionGraph,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user