feat: enhance media library and lifecycle management
All checks were successful
Deploy Development / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 23s

- Updated media library to include lifecycle filtering options (active, trash_soft, trash_hidden) and copyright management capabilities.
- Implemented new API endpoints for listing media assets with lifecycle states and patching copyright notices.
- Enhanced frontend components to support navigation to the media library and integration of media management features in the ExerciseFormPage.
- Incremented version to 0.8.48, reflecting the latest improvements in media handling and governance.
This commit is contained in:
Lars 2026-05-07 14:10:26 +02:00
parent 95f5b0b2d7
commit 0a1816e38b
11 changed files with 571 additions and 21 deletions

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo - Projekt-Status
**Stand:** 2026-05-07
**Version (Code):** 0.8.43 (`backend/version.py`, APP_VERSION)
**Version (Code):** 0.8.48 (`backend/version.py`, APP_VERSION)
**DB-Schema-Version:** `20260507045` (u. a. `media_assets`, `platform_media_storage`)
**Branch:** develop
@ -9,7 +9,7 @@
## Executive Summary
**Aktueller Meilenstein (Medien):** **Archiv & Wiederverwendung** auf Basis von **Migration 045** (`media_assets`, `exercise_media.media_asset_id`, Superadmin-Speicherpfad). **Papierkorb §5** (Lifecycle-API, Retention-Job), **Archiv-Liste** (`GET /api/media-assets`), **Direkt-Download** für Thumbnails (`GET /api/media-assets/{id}/file`), **Verknüpfen ohne Upload** (`POST /api/exercises/{id}/media/from-asset`), Übungsformular mit Archiv-Dialog und Vorschau sind umgesetzt.
**Aktueller Meilenstein (Medien):** **Medienbibliothek `/media`** ergänzt den Archiv-Picker: **Lifecycle-Filter** (aktiv / Papierkorb / ausgeblendet), **Copyright bearbeiten** (`PATCH`), **Vorschau** inkl. Papierkorb bei Verwaltungsrecht; API-Liste `copyright_notice`; zuvor **§4.2 Promotion official** (Übung + Medien).
**Parallel weiter relevant:** **Trainingsrahmenprogramm** (036037), **Progressionsgraph** (032034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**.
@ -17,8 +17,8 @@
**Nächste Schritte — Medien & Archiv** (neu priorisiert, Stand 2026-05-07):
1. **Übung → `official` Promotion** inkl. Medien-Anhebung + **Copyright-Pflicht** bei `official` (Spec §4.2) — höchste fachliche Lücke zur Governance.
2. **Eigenständige Medienmanager-Seite** (Admin/Verein): Metadaten/Copyright bearbeiten, Filtern nach Lifecycle, Bulk — ergänzt den in der Übung eingebetteten Archiv-Picker.
1. ~~**Übung → `official` Promotion** inkl. Medien-Anhebung + **Copyright-Pflicht** bei `official` (Spec §4.2)~~ — umgesetzt (0.8.47).
2. ~~**Eigenständige Medienmanager-Seite**~~**Basis umgesetzt** (`/media`): Filter, Copyright, Lifecycle-Aktionen; Ausbau: Sichtbarkeit bearbeiten, Bulk, Quotas.
3. **Tests & Observability:** gezielte pytest-Abdeckung für Archiv/Verknüpfen; optional Retention-Job-Dry-Run dokumentieren.
4. **S3 / externes Backend** hinter Speicher-Abstraktion (Spec §7) — nach stabiler Nutzung lokaler/NAS-Pfade.
5. **Inline-Medien im Fließtext** (Spec §11) — bewusst **nach** Promotion/Copyright und tragfähigem Archiv-Workflow.
@ -104,8 +104,8 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu
### 🔲 In Arbeit / Backlog
- [x] **Medien:** Papierkorb (§5), Retention-Job, Archiv-API, „Aus Archiv verknüpfen“, Picker/Vorschau in Übungsbearbeitung (Release 0.8.42 ff.)
- [ ] **Medien:** Promotion Übung↔Medien + Copyright-Pflicht `official` (Spec §4.2)
- [ ] **Medien:** Eigenständige Medienmanager-UI (Admin/Verein), S3 — Roadmap Executive Summary
- [x] **Medien:** Promotion Übung↔Medien + Copyright-Pflicht `official` (Spec §4.2) — 0.8.47
- [x] **Medien:** Medienbibliothek `/media` (Lifecycle-Filter, Copyright PATCH, Vorschau); Ausbau Manager/Bulk/S3 — Roadmap
- [ ] **Medien:** Inline im Fließtext — nach Spec §11, nach Promotion/Archiv-Reife
- [ ] Admin-UI für Skill-Kategorien (CRUD) falls noch offen
- [ ] Responsive Design / Dark Mode / PWA

View File

@ -188,7 +188,7 @@ Das widerspricht **nicht** dem Papierkorb: wer kein Recht hat, ein Asset **globa
|-------|----------|
| 2026-05-07 | Erstfassung als Single Source of Truth (verbindlich); Abstimmung mit Stakeholder: Promotion Übung↔Medien, Copyright, Papierkorb 3-stufig, externe Speicher, Embeds getrennt vom Asset-Lifecycle. |
| 2026-05-07 | §11 **Inline-Medien im Fließtext**: Leitplanken (Anker `exercise_media.id`, einheitlicher Render-Pfad, keine zweite Governance); Zeitpunkt der Umsetzung; Drift-Vermeidung ohne jetzigen Vollbau. |
| 2026-05-07 | **Promotion Übung → `official` (Umsetzung):** PUT `/api/exercises/{id}` und `PATCH /api/exercises/bulk-metadata` prüfen angehängte `media_assets` (aktiv, Sichtbarkeit, Copyright ≥3 Zeichen); optional `promote_attached_media_for_official` + `default_official_media_copyright`; Antwort-Codes `OFFICIAL_MEDIA_LIFECYCLE`, `OFFICIAL_MEDIA_CONFIRM_REQUIRED`. |
| 2026-05-07 | **Medienmanager (Basis):** `GET /api/media-assets?lifecycle=…`, `copyright_notice` in Response; `PATCH` Copyright; `GET …/file` für Papierkorb-Zeilen bei Verwaltungsrecht; UI-Route `/media` (Medienbibliothek). |
---

View File

@ -19,8 +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 | `trash_soft` / `trash_hidden` / `recover` / `purge` / **`reactivate`** (Papierkorb → aktiv); Rechte `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 |
| media_assets | `GET /api/media-assets` | ja | `get_tenant_context` | ja | optional `lifecycle`; Standard `active`; Liste inkl. `copyright_notice`; Papierkorb-Ansicht nur sichtbare Mandanten-Assets |
| media_assets | `PATCH /api/media-assets/{id}` | ja | `get_tenant_context` | ja | Metadaten (Copyright); `assert_can_manage_media_asset_lifecycle` |
| media_assets | `GET /api/media-assets/{id}/file` | ja | `get_tenant_context_flexible` | ja | aktiv: Bibliotheks-Sichtbarkeit; `trash_soft`/`trash_hidden`: wie Lifecycle-Verwaltung |
| 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 |

View File

@ -5,7 +5,7 @@ from typing import Any, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
from pydantic import BaseModel, Field
from club_tenancy import is_platform_admin, library_content_visible_to_profile
from db import get_db, get_cursor, r2d
@ -20,6 +20,26 @@ class MediaLifecycleBody(BaseModel):
action: Literal["trash_soft", "trash_hidden", "recover", "purge", "reactivate"]
class MediaAssetPatch(BaseModel):
copyright_notice: Optional[str] = Field(None, max_length=8000)
_LIFECYCLE_LIST_FILTERS = frozenset({"active", "trash_soft", "trash_hidden", "all"})
def _lifecycle_where_sql(lifecycle: str) -> str:
lc = (lifecycle or "active").strip().lower()
if lc not in _LIFECYCLE_LIST_FILTERS:
raise HTTPException(status_code=400, detail="Ungültiger lifecycle-Filter")
if lc == "active":
return "ma.lifecycle_state = 'active'"
if lc == "trash_soft":
return "ma.lifecycle_state = 'trash_soft'"
if lc == "trash_hidden":
return "ma.lifecycle_state = 'trash_hidden'"
return "ma.lifecycle_state IN ('active', 'trash_soft', 'trash_hidden')"
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,
@ -47,12 +67,18 @@ def _assert_can_view_archive_asset(cur: Any, tenant: TenantContext, asset: dict)
def list_media_assets(
tenant: TenantContext = Depends(get_tenant_context),
q: Optional[str] = Query(None, max_length=120),
lifecycle: str = Query(
"active",
description="active | trash_soft | trash_hidden | all (nicht purgierte Zustände)",
),
limit: int = Query(30, ge=1, le=100),
offset: int = Query(0, ge=0),
):
"""
Durchsuchbares Medien-Archiv: nur aktive Assets, Sichtbarkeit wie Übungsbibliothek.
Durchsuchbares Medien-Archiv; Sichtbarkeit wie Übungsbibliothek.
Standard lifecycle=active (Archiv-Picker); Manager-UI kann Papierkorb-Ansicht wählen.
"""
lc_where = _lifecycle_where_sql(lifecycle)
role = tenant.global_role or ""
is_adm = is_platform_admin(role)
profile_id = tenant.profile_id
@ -69,9 +95,10 @@ def list_media_assets(
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
ma.uploaded_by_profile_id, ma.lifecycle_state, ma.created_at, ma.sha256,
ma.copyright_notice
FROM media_assets ma
WHERE ma.lifecycle_state = 'active'
WHERE {lc_where}
AND (
%s
OR lower(trim(ma.visibility)) = 'official'
@ -90,12 +117,12 @@ def list_media_assets(
)
)
{search_sql}
ORDER BY ma.created_at DESC
ORDER BY ma.updated_at DESC NULLS LAST, ma.created_at DESC
LIMIT %s OFFSET %s""",
params,
)
rows = [r2d(r) for r in cur.fetchall()]
return {"items": rows, "limit": limit, "offset": offset}
return {"items": rows, "limit": limit, "offset": offset, "lifecycle": lifecycle.strip().lower()}
@router.api_route("/{asset_id}/file", methods=["GET", "HEAD"])
@ -113,9 +140,14 @@ def download_media_asset_file(
if not asset:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
lc = (asset.get("lifecycle_state") or "").strip().lower()
if lc != "active":
if lc == "active":
_assert_can_view_archive_asset(cur, tenant, asset)
elif lc in ("trash_soft", "trash_hidden"):
from media_lifecycle import assert_can_manage_media_asset_lifecycle
assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
else:
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:
@ -177,3 +209,47 @@ def post_media_asset_lifecycle(
return reactivate_media_asset_from_trash(cur, conn, asset_id)
raise HTTPException(status_code=500, detail="Interner Fehler: lifecycle action")
@router.patch("/{asset_id}")
def patch_media_asset(
asset_id: int,
body: MediaAssetPatch,
tenant: TenantContext = Depends(get_tenant_context),
):
"""Metadaten (z. B. Copyright) — gleiche Berechtigung wie Lifecycle-Verwaltung."""
from media_lifecycle import assert_can_manage_media_asset_lifecycle
data = body.dict(exclude_unset=True)
if not data:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state,
copyright_notice, original_filename
FROM media_assets WHERE id = %s""",
(asset_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
asset = r2d(row)
assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
if "copyright_notice" in data:
cn = data["copyright_notice"]
cur.execute(
"UPDATE media_assets SET copyright_notice = %s, updated_at = NOW() WHERE id = %s",
(cn, asset_id),
)
conn.commit()
cur.execute(
"""SELECT id, mime_type, byte_size, original_filename, visibility, club_id,
uploaded_by_profile_id, lifecycle_state, created_at, sha256, copyright_notice
FROM media_assets WHERE id = %s""",
(asset_id,),
)
out = r2d(cur.fetchone())
return out

View File

@ -59,6 +59,7 @@ def test_list_media_assets_ok_mocked(client: TestClient) -> None:
"lifecycle_state": "active",
"created_at": None,
"sha256": "a" * 64,
"copyright_notice": None,
}
]
mock_cm = _mock_db(mock_cur)
@ -312,3 +313,86 @@ def test_media_asset_lifecycle_reactivate_mocked(client: TestClient) -> None:
assert r.status_code == 200
assert r.json()["lifecycle_state"] == "active"
def test_list_media_assets_lifecycle_trash_soft_mocked(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=10,
global_role="trainer",
effective_club_id=5,
club_ids=frozenset({5}),
memberships=[],
)
mock_cur = MagicMock()
mock_cur.fetchall.return_value = []
mock_cm = _mock_db(mock_cur)
with patch("routers.media_assets.get_db", return_value=mock_cm), patch(
"routers.media_assets.get_cursor", return_value=mock_cur
):
r = client.get("/api/media-assets?lifecycle=trash_soft", headers={"X-Auth-Token": "t"})
assert r.status_code == 200
assert r.json()["lifecycle"] == "trash_soft"
first_sql = mock_cur.execute.call_args_list[0][0][0]
assert "trash_soft" in first_sql
def test_list_media_assets_invalid_lifecycle_400(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=10,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
r = client.get("/api/media-assets?lifecycle=invalid", headers={"X-Auth-Token": "t"})
assert r.status_code == 400
def test_patch_media_asset_copyright_mocked(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=10,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{
"id": 9,
"visibility": "private",
"club_id": None,
"uploaded_by_profile_id": 10,
"lifecycle_state": "active",
"copyright_notice": "",
"original_filename": "x.png",
},
{
"id": 9,
"mime_type": "image/png",
"byte_size": 10,
"original_filename": "x.png",
"visibility": "private",
"club_id": None,
"uploaded_by_profile_id": 10,
"lifecycle_state": "active",
"created_at": None,
"sha256": "b" * 64,
"copyright_notice": "© HoldCo",
},
]
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("media_lifecycle.assert_can_manage_media_asset_lifecycle", lambda *a, **k: None):
r = client.patch(
"/api/media-assets/9",
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
json={"copyright_notice": "© HoldCo"},
)
assert r.status_code == 200
assert r.json()["copyright_notice"] == "© HoldCo"

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.47"
APP_VERSION = "0.8.48"
BUILD_DATE = "2026-05-07"
DB_SCHEMA_VERSION = "20260507045"
@ -13,7 +13,7 @@ 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.2.0", # lifecycle action reactivate (Papierkorb → aktiv)
"media_assets": "1.3.0", # Liste lifecycle-Filter; PATCH Metadaten; GET /file für Papierkorb bei Verwaltungsrecht
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
@ -29,6 +29,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.48",
"date": "2026-05-07",
"changes": [
"Medienbibliothek: GET /api/media-assets mit lifecycle (active|trash_soft|trash_hidden|all), copyright_notice in Liste; PATCH /api/media-assets/{id} (Copyright); GET …/file für Papierkorb wenn Lifecycle-Recht; Frontend /media + Admin-Nav + Link Übungsformular",
],
},
{
"version": "0.8.47",
"date": "2026-05-07",

View File

@ -32,6 +32,7 @@ import AdminMaturityModelsPage from './pages/AdminMaturityModelsPage'
import TrainerContextsPage from './pages/TrainerContextsPage'
import MediaWikiImportPage from './pages/MediaWikiImportPage'
import AdminUsersPage from './pages/AdminUsersPage'
import MediaLibraryPage from './pages/MediaLibraryPage'
import ActiveClubSwitcher from './components/ActiveClubSwitcher'
import './app.css'
@ -158,6 +159,7 @@ function AppRoutes() {
<Route path="profile" element={<Navigate to="/settings" replace />} />
<Route path="settings" element={<AccountSettingsPage />} />
<Route path="settings/system" element={<SettingsSystemInfoPage />} />
<Route path="media" element={<MediaLibraryPage />} />
<Route path="exercises">
<Route index element={<ExercisesListPage />} />
<Route path="new" element={<ExerciseFormPage />} />

View File

@ -1,5 +1,5 @@
import { NavLink } from 'react-router-dom'
import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react'
import { TreePine, FolderTree, Download, Grid3x3, Users, Images } from 'lucide-react'
/**
* Admin-Seiten-Navigation (horizontal)
@ -11,6 +11,7 @@ export default function AdminPageNav() {
{ to: '/admin/users', label: 'Nutzer', icon: Users },
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
{ to: '/media', label: 'Medien', icon: Images },
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download }
]

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState, useRef, useMemo } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useNavigate, useParams, Link } from 'react-router-dom'
import api, { buildExerciseApiPayload } from '../utils/api'
import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
import RichTextEditor from '../components/RichTextEditor'
@ -1451,6 +1451,9 @@ function ExerciseFormPage() {
<button type="button" className="btn btn-secondary" onClick={() => setArchiveOpen(true)}>
Aus Archiv verknüpfen
</button>
<Link to="/media" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
Medienbibliothek
</Link>
</div>
<div style={{ display: 'grid', gap: '12px', marginTop: '12px' }}>
<div>

View File

@ -0,0 +1,367 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
import AdminPageNav from '../components/AdminPageNav'
import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
const LC_OPTIONS = [
{ value: 'active', label: 'Aktiv' },
{ value: 'trash_soft', label: 'Papierkorb (Stufe 1)' },
{ value: 'trash_hidden', label: 'Ausgeblendet (Stufe 2)' },
{ value: 'all', label: 'Alle (nicht purgiert)' },
]
function lcLabel(code) {
const o = LC_OPTIONS.find((x) => x.value === code)
return o ? o.label : code
}
export default function MediaLibraryPage() {
const { user } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const [lifecycle, setLifecycle] = useState('active')
const [q, setQ] = useState('')
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
/** @type {Record<number, string>} */
const [copyrightDrafts, setCopyrightDrafts] = useState({})
const [busyId, setBusyId] = useState(null)
useEffect(() => {
let cancelled = false
const t = setTimeout(async () => {
setLoading(true)
setError('')
try {
const res = await api.listMediaAssets({
lifecycle,
q: q.trim(),
limit: 100,
offset: 0,
})
if (cancelled) return
setItems(res.items || [])
const nx = {}
for (const it of res.items || []) {
nx[it.id] = it.copyright_notice != null ? String(it.copyright_notice) : ''
}
setCopyrightDrafts(nx)
} catch (e) {
if (!cancelled) setError(e.message || String(e))
} finally {
if (!cancelled) setLoading(false)
}
}, 320)
return () => {
cancelled = true
clearTimeout(t)
}
}, [lifecycle, q])
const refresh = async () => {
setLoading(true)
setError('')
try {
const res = await api.listMediaAssets({
lifecycle,
q: q.trim(),
limit: 100,
offset: 0,
})
setItems(res.items || [])
const nx = {}
for (const it of res.items || []) {
nx[it.id] = it.copyright_notice != null ? String(it.copyright_notice) : ''
}
setCopyrightDrafts(nx)
} catch (e) {
setError(e.message || String(e))
} finally {
setLoading(false)
}
}
const saveCopyright = async (id) => {
const text = copyrightDrafts[id]
if (text === undefined) return
setBusyId(id)
try {
await api.patchMediaAsset(id, { copyright_notice: text })
await refresh()
} catch (e) {
alert(e.message || String(e))
} finally {
setBusyId(null)
}
}
const runLifecycle = async (id, action, confirmMsg) => {
if (confirmMsg && !window.confirm(confirmMsg)) return
setBusyId(id)
try {
await api.postMediaAssetLifecycle(id, action)
await refresh()
} catch (e) {
alert(e.message || String(e))
} finally {
setBusyId(null)
}
}
return (
<div className="app-page media-library-page">
{isPlatformAdmin ? <AdminPageNav /> : null}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'baseline',
gap: '12px',
marginBottom: '12px',
}}
>
<h1 className="page-title" style={{ margin: 0 }}>
Medienbibliothek
</h1>
<Link to="/exercises" style={{ fontSize: '14px' }}>
Zu den Übungen
</Link>
{isPlatformAdmin ? (
<Link to="/admin/hierarchy" style={{ fontSize: '14px' }}>
Administration
</Link>
) : null}
</div>
<p style={{ color: 'var(--text2)', fontSize: '14px', maxWidth: '52rem', marginTop: 0 }}>
Sichtbare Medien gemäß deinen Rechten (privat, Verein, offiziell). Papierkorb- und
Metadaten-Aktionen wie in der Übungsbearbeitung hier zentral mit Filter.
</p>
<div
className="form-row"
style={{ flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end', marginBottom: '16px' }}
>
<div>
<label className="form-label" htmlFor="media-lib-q">
Suche
</label>
<input
id="media-lib-q"
type="search"
className="form-input"
placeholder="Dateiname …"
value={q}
onChange={(e) => setQ(e.target.value)}
style={{ minWidth: '220px' }}
/>
</div>
<div>
<label className="form-label" htmlFor="media-lib-lc">
Lebenszyklus
</label>
<select
id="media-lib-lc"
className="form-input"
value={lifecycle}
onChange={(e) => setLifecycle(e.target.value)}
>
{LC_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<button type="button" className="btn btn-secondary" onClick={refresh} disabled={loading}>
Aktualisieren
</button>
</div>
{error ? (
<p style={{ color: 'var(--danger, crimson)' }} role="alert">
{error}
</p>
) : null}
{loading && items.length === 0 ? <div className="spinner" /> : null}
{!loading && !error && items.length === 0 ? (
<p style={{ color: 'var(--text2)' }}>Keine Einträge.</p>
) : null}
<div style={{ overflowX: 'auto' }}>
<table className="table" style={{ width: '100%', fontSize: '14px' }}>
<thead>
<tr>
<th style={{ width: 56 }} aria-hidden />
<th>Datei</th>
<th>Sichtbarkeit</th>
<th>Status</th>
<th style={{ minWidth: '220px' }}>Copyright</th>
<th style={{ minWidth: '200px' }}>Aktionen</th>
</tr>
</thead>
<tbody>
{items.map((it) => {
const mime = (it.mime_type || '').toLowerCase()
const isImg = mime.startsWith('image/')
const busy = busyId === it.id
const lc = (it.lifecycle_state || '').toLowerCase()
return (
<tr key={it.id}>
<td>
{isImg ? (
<img
src={resolveMediaAssetFileUrl(it.id)}
alt=""
width={48}
height={48}
loading="lazy"
style={{
objectFit: 'cover',
borderRadius: 6,
background: 'var(--surface2, #eee)',
}}
onError={(e) => {
e.target.style.visibility = 'hidden'
}}
/>
) : (
<div
style={{
width: 48,
height: 48,
borderRadius: 6,
background: 'var(--surface2, #eee)',
fontSize: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text2)',
}}
>
{mime.includes('video') ? '▶' : '◆'}
</div>
)}
</td>
<td>
<div style={{ fontWeight: 600 }}>{it.original_filename || `Asset #${it.id}`}</div>
<div style={{ fontSize: '12px', color: 'var(--text2)' }}>
ID {it.id}
{it.byte_size != null ? ` · ${Math.round(it.byte_size / 1024)} KB` : ''}
</div>
</td>
<td>{it.visibility}</td>
<td>{lcLabel(lc)}</td>
<td>
<textarea
className="form-input"
rows={2}
style={{ width: '100%', resize: 'vertical', minHeight: '48px' }}
value={copyrightDrafts[it.id] ?? ''}
onChange={(e) =>
setCopyrightDrafts((prev) => ({ ...prev, [it.id]: e.target.value }))
}
disabled={busy}
/>
<button
type="button"
className="btn btn-secondary"
style={{ marginTop: 6 }}
disabled={busy}
onClick={() => saveCopyright(it.id)}
>
Copyright speichern
</button>
</td>
<td>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{lc === 'active' ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() =>
runLifecycle(
it.id,
'trash_soft',
'Medium in den Papierkorb (Stufe 1) legen?',
)
}
>
Papierkorb
</button>
) : null}
{lc === 'trash_soft' ? (
<>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() => runLifecycle(it.id, 'reactivate', null)}
>
Wieder aktiv
</button>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() =>
runLifecycle(
it.id,
'trash_hidden',
'Medium ausblenden (Stufe 2)? In öffentlichen Ansichten unsichtbar.',
)
}
>
Ausblenden
</button>
</>
) : null}
{lc === 'trash_hidden' ? (
<>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() => runLifecycle(it.id, 'recover', null)}
>
Zurück Stufe 1
</button>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() => runLifecycle(it.id, 'reactivate', null)}
>
Wieder aktiv
</button>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() =>
runLifecycle(
it.id,
'purge',
'Endgültig löschen (Datei und DB-Eintrag)?',
)
}
>
Endgültig löschen
</button>
</>
) : null}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}

View File

@ -534,10 +534,18 @@ export async function listMediaAssets(params = {}) {
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))
if (params.lifecycle) sp.set('lifecycle', String(params.lifecycle))
const qs = sp.toString()
return request(`/api/media-assets${qs ? `?${qs}` : ''}`)
}
export async function patchMediaAsset(assetId, data) {
return request(`/api/media-assets/${assetId}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
}
/** Ü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`, {
@ -1284,6 +1292,7 @@ export const api = {
reorderExerciseMedia,
postMediaAssetLifecycle,
listMediaAssets,
patchMediaAsset,
attachExerciseMediaFromAsset,
listExerciseProgressionGraphs,
getExerciseProgressionGraph,