From 0a1816e38bc70a05f8d350f07926ea95e49e175d Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 7 May 2026 14:10:26 +0200 Subject: [PATCH] feat: enhance media library and lifecycle management - 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. --- .claude/docs/PROJECT_STATUS.md | 12 +- .../MEDIA_ASSETS_AND_ARCHIVE_SPEC.md | 2 +- .../working/ACCESS_LAYER_ENDPOINT_AUDIT.md | 5 +- backend/routers/media_assets.py | 92 ++++- backend/tests/test_media_assets_archive.py | 84 ++++ backend/version.py | 11 +- frontend/src/App.jsx | 2 + frontend/src/components/AdminPageNav.jsx | 3 +- frontend/src/pages/ExerciseFormPage.jsx | 5 +- frontend/src/pages/MediaLibraryPage.jsx | 367 ++++++++++++++++++ frontend/src/utils/api.js | 9 + 11 files changed, 571 insertions(+), 21 deletions(-) create mode 100644 frontend/src/pages/MediaLibraryPage.jsx diff --git a/.claude/docs/PROJECT_STATUS.md b/.claude/docs/PROJECT_STATUS.md index 9bcd706..c0333bc 100644 --- a/.claude/docs/PROJECT_STATUS.md +++ b/.claude/docs/PROJECT_STATUS.md @@ -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** (036–037), **Progressionsgraph** (032–034) — 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 diff --git a/.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md b/.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md index 45854c9..5aa91d8 100644 --- a/.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md +++ b/.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md @@ -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). | --- diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index f72a770..0f512e1 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -19,8 +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 | `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 | diff --git a/backend/routers/media_assets.py b/backend/routers/media_assets.py index 872d3b5..edadb18 100644 --- a/backend/routers/media_assets.py +++ b/backend/routers/media_assets.py @@ -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 diff --git a/backend/tests/test_media_assets_archive.py b/backend/tests/test_media_assets_archive.py index e44609d..01e976f 100644 --- a/backend/tests/test_media_assets_archive.py +++ b/backend/tests/test_media_assets_archive.py @@ -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" + diff --git a/backend/version.py b/backend/version.py index e120465..4c5cc83 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e8b1e43..1c68048 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/AdminPageNav.jsx b/frontend/src/components/AdminPageNav.jsx index 2e6aa4c..adf2221 100644 --- a/frontend/src/components/AdminPageNav.jsx +++ b/frontend/src/components/AdminPageNav.jsx @@ -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 } ] diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx index b9b7e38..6f398ec 100644 --- a/frontend/src/pages/ExerciseFormPage.jsx +++ b/frontend/src/pages/ExerciseFormPage.jsx @@ -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() { + + Medienbibliothek +
diff --git a/frontend/src/pages/MediaLibraryPage.jsx b/frontend/src/pages/MediaLibraryPage.jsx new file mode 100644 index 0000000..4449efc --- /dev/null +++ b/frontend/src/pages/MediaLibraryPage.jsx @@ -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} */ + 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 ( +
+ {isPlatformAdmin ? : null} +
+

+ Medienbibliothek +

+ + Zu den Übungen + + {isPlatformAdmin ? ( + + Administration + + ) : null} +
+ +

+ Sichtbare Medien gemäß deinen Rechten (privat, Verein, offiziell). Papierkorb- und + Metadaten-Aktionen wie in der Übungsbearbeitung — hier zentral mit Filter. +

+ +
+
+ + setQ(e.target.value)} + style={{ minWidth: '220px' }} + /> +
+
+ + +
+ +
+ + {error ? ( +

+ {error} +

+ ) : null} + {loading && items.length === 0 ?
: null} + + {!loading && !error && items.length === 0 ? ( +

Keine Einträge.

+ ) : null} + +
+ + + + + + + + + + + + {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 ( + + + + + +
+ DateiSichtbarkeitStatusCopyrightAktionen
+ {isImg ? ( + { + e.target.style.visibility = 'hidden' + }} + /> + ) : ( +
+ {mime.includes('video') ? '▶' : '◆'} +
+ )} +
+
{it.original_filename || `Asset #${it.id}`}
+
+ ID {it.id} + {it.byte_size != null ? ` · ${Math.round(it.byte_size / 1024)} KB` : ''} +
+
{it.visibility}{lcLabel(lc)} +