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
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:
parent
95f5b0b2d7
commit
0a1816e38b
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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). |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
367
frontend/src/pages/MediaLibraryPage.jsx
Normal file
367
frontend/src/pages/MediaLibraryPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user