feat: increment version to 0.8.50 and enhance media asset features
All checks were successful
Deploy Development / deploy (push) Successful in 35s
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 30s

- Updated APP_VERSION to 0.8.50 and DB_SCHEMA_VERSION to 20260507046.
- Enhanced media assets with new tagging functionality, allowing users to filter and search by tags.
- Improved media library UI with new filters for media kind and uploader, enhancing user experience.
- Updated changelog to reflect the latest changes and improvements in media management.
This commit is contained in:
Lars 2026-05-07 16:15:07 +02:00
parent 3d321857ec
commit 1bc7ea95fb
7 changed files with 637 additions and 52 deletions

View File

@ -0,0 +1,5 @@
-- Migration 046: Schlagwörter (tags) für media_assets — Suche & Filter in der Medienbibliothek.
ALTER TABLE media_assets
ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}';
CREATE INDEX IF NOT EXISTS idx_media_assets_tags_gin ON media_assets USING GIN (tags);

View File

@ -1,7 +1,7 @@
"""Medien-Archiv (Liste, Datei) und Lifecycle — MEDIA_ASSETS_AND_ARCHIVE_SPEC.""" """Medien-Archiv (Liste, Datei) und Lifecycle — MEDIA_ASSETS_AND_ARCHIVE_SPEC."""
from __future__ import annotations from __future__ import annotations
from typing import Any, Literal, Optional from typing import Any, Literal, Optional, Union
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
@ -63,6 +63,7 @@ class MediaAssetPatch(BaseModel):
original_filename: Optional[str] = Field(None, max_length=300) original_filename: Optional[str] = Field(None, max_length=300)
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$") visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
club_id: Optional[int] = None club_id: Optional[int] = None
tags: Optional[list[str]] = None
class MediaBulkLifecycleBody(BaseModel): class MediaBulkLifecycleBody(BaseModel):
@ -93,9 +94,157 @@ class MediaBulkPatchBody(BaseModel):
original_filename: Optional[str] = Field(None, max_length=300) original_filename: Optional[str] = Field(None, max_length=300)
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$") visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
club_id: Optional[int] = None club_id: Optional[int] = None
tags: Optional[list[str]] = None
_LIFECYCLE_LIST_FILTERS = frozenset({"active", "trash_soft", "trash_hidden", "all"}) _LIFECYCLE_LIST_FILTERS = frozenset({"active", "trash_soft", "trash_hidden", "all"})
_MEDIA_KIND_FILTERS = frozenset({"all", "image", "video", "pdf", "other"})
_MAX_TAGS = 40
_MAX_TAG_LEN = 48
def _normalize_media_tags(raw: Union[list[str], None]) -> list[str]:
"""PostgreSQL text[]; Einträge gekürzt und ohne Dubletten (case-insensitive)."""
if raw is None:
return []
if not isinstance(raw, list):
raise HTTPException(status_code=400, detail="tags muss eine Liste von Strings sein")
out: list[str] = []
seen: set[str] = set()
for item in raw:
s = str(item).strip()
if not s:
continue
s = s[:_MAX_TAG_LEN]
key = s.lower()
if key in seen:
continue
seen.add(key)
out.append(s)
if len(out) >= _MAX_TAGS:
break
return out
def _usage_for_media_assets(cur: Any, asset_ids: list[int]) -> dict[int, dict[str, Any]]:
"""Übungen und Trainingseinheiten, die dieses Archiv-Medium nutzen."""
if not asset_ids:
return {}
cur.execute(
"""SELECT em.media_asset_id, e.id AS exercise_id, e.title AS exercise_title
FROM exercise_media em
JOIN exercises e ON e.id = em.exercise_id
WHERE em.media_asset_id = ANY(%s)
ORDER BY em.media_asset_id, e.title NULLS LAST, e.id""",
(asset_ids,),
)
ex_by_asset: dict[int, list[dict]] = {int(aid): [] for aid in asset_ids}
seen_ex: dict[int, set[int]] = {int(aid): set() for aid in asset_ids}
exercise_ids: set[int] = set()
for r in cur.fetchall():
d = r2d(r)
aid = int(d["media_asset_id"])
eid = int(d["exercise_id"])
if aid not in seen_ex or eid in seen_ex[aid]:
continue
seen_ex[aid].add(eid)
exercise_ids.add(eid)
title = (d.get("exercise_title") or "").strip() or f"Übung #{eid}"
ex_by_asset[aid].append({"id": eid, "title": title})
exercise_to_units: dict[int, list[dict]] = {}
if exercise_ids:
cur.execute(
"""SELECT tue.exercise_id, tu.id AS unit_id, tu.planned_date,
COALESCE(tg.name, '') AS group_name
FROM training_unit_exercises tue
JOIN training_units tu ON tu.id = tue.training_unit_id
LEFT JOIN training_groups tg ON tg.id = tu.group_id
WHERE tue.exercise_id = ANY(%s)""",
(list(exercise_ids),),
)
for row in cur.fetchall():
d = r2d(row)
eid = int(d["exercise_id"])
uid = int(d["unit_id"])
pd = d.get("planned_date")
planned: Optional[str]
if pd is None:
planned = None
elif hasattr(pd, "isoformat"):
planned = str(pd.isoformat())
else:
planned = str(pd)
exercise_to_units.setdefault(eid, []).append(
{
"id": uid,
"planned_date": planned,
"group_name": d.get("group_name") or "",
}
)
for eid, units in exercise_to_units.items():
seen_u: set[int] = set()
uniq: list[dict] = []
for u in units:
if u["id"] not in seen_u:
seen_u.add(u["id"])
uniq.append(u)
exercise_to_units[eid] = uniq
out: dict[int, dict[str, Any]] = {}
for aid in asset_ids:
aid_i = int(aid)
exs = ex_by_asset.get(aid_i, [])
seen_u2: set[int] = set()
units_agg: list[dict] = []
for ex in exs:
for u in exercise_to_units.get(ex["id"], []):
if u["id"] not in seen_u2:
seen_u2.add(u["id"])
units_agg.append(u)
out[aid_i] = {"exercises": exs, "training_units": units_agg}
return out
def _fetch_filter_uploaders(cur: Any, is_adm: bool, profile_id: int) -> list[dict]:
cur.execute(
"""SELECT DISTINCT ma.uploaded_by_profile_id AS id, pr.name, pr.email
FROM media_assets ma
LEFT JOIN profiles pr ON pr.id = ma.uploaded_by_profile_id
WHERE ma.uploaded_by_profile_id IS NOT NULL
AND ma.lifecycle_state IN ('active', 'trash_soft', 'trash_hidden')
AND (
%s
OR lower(trim(ma.visibility)) = 'official'
OR (
lower(trim(ma.visibility)) = 'private'
AND ma.uploaded_by_profile_id = %s
)
OR (
lower(trim(ma.visibility)) = 'club'
AND EXISTS (
SELECT 1 FROM club_members cm
WHERE cm.profile_id = %s
AND cm.club_id = ma.club_id
AND cm.status = 'active'
)
)
)
ORDER BY pr.name NULLS LAST, ma.uploaded_by_profile_id
LIMIT 400""",
(is_adm, profile_id, profile_id),
)
rows = [r2d(x) for x in cur.fetchall()]
out: list[dict] = []
for r in rows:
pid = r.get("id")
if pid is None:
continue
label = (r.get("name") or "").strip()
if not label:
label = (r.get("email") or "").strip()
if not label:
label = f"Profil #{pid}"
out.append({"id": int(pid), "label": label})
return out
def _effective_media_patch_fields(patch_fields: dict, asset: dict) -> dict: def _effective_media_patch_fields(patch_fields: dict, asset: dict) -> dict:
@ -264,29 +413,101 @@ def list_media_assets(
"active", "active",
description="active | trash_soft | trash_hidden | all (nicht purgierte Zustände)", description="active | trash_soft | trash_hidden | all (nicht purgierte Zustände)",
), ),
media_kind: str = Query(
"all",
description="all | image | video | pdf | other",
),
club_id: Optional[int] = Query(
None,
ge=1,
description="Nur Superadmin: nach Verein filtern",
),
uploaded_by: Optional[int] = Query(
None,
ge=1,
description="Nach Uploader (Profil-ID), wenn erlaubt",
),
include_filter_meta: bool = Query(
False,
description="Uploader-Liste für Filter (nur wenn Metadaten sichtbar)",
),
limit: int = Query(30, ge=1, le=100), limit: int = Query(30, ge=1, le=100),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
): ):
lc_where = _lifecycle_where_sql(lifecycle) lc_where = _lifecycle_where_sql(lifecycle)
mk = (media_kind or "all").strip().lower()
if mk not in _MEDIA_KIND_FILTERS:
raise HTTPException(status_code=400, detail="Ungültiger media_kind")
role = tenant.global_role or "" role = tenant.global_role or ""
is_adm = is_platform_admin(role) is_adm = is_platform_admin(role)
sup = is_superadmin(role)
profile_id = tenant.profile_id profile_id = tenant.profile_id
needle = (q or "").strip() needle = (q or "").strip()
params: list[Any] = [is_adm, profile_id, profile_id]
search_sql = "" if club_id is not None and not sup:
if needle: raise HTTPException(status_code=403, detail="Vereinsfilter nur für Superadmin")
like = f"%{needle}%"
params.extend([like, like]) media_kind_sql = ""
search_sql = " AND (ma.original_filename ILIKE %s OR ma.storage_key ILIKE %s)" if mk == "image":
params.extend([limit, offset]) 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/%'"
elif mk == "pdf":
media_kind_sql = (
" AND (lower(COALESCE(ma.mime_type, '')) = 'application/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) <> 'application/pdf'"
" AND lower(ma.mime_type) NOT LIKE '%pdf%'"
)
club_sql = ""
club_sql_params: list[Any] = []
if club_id is not None:
club_sql = " AND ma.club_id = %s"
club_sql_params.append(club_id)
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
admin_club_ids = club_ids_for_profile_with_roles(cur, profile_id, "club_admin") admin_club_ids = club_ids_for_profile_with_roles(cur, profile_id, "club_admin")
show_uploader = sup or is_adm or bool(admin_club_ids)
if uploaded_by is not None and not show_uploader:
raise HTTPException(status_code=403, detail="Uploader-Filter nicht erlaubt")
uploaded_sql = ""
uploaded_params: list[Any] = []
if uploaded_by is not None:
uploaded_sql = " AND ma.uploaded_by_profile_id = %s"
uploaded_params.append(uploaded_by)
search_sql = ""
search_params: list[Any] = []
if needle:
like = f"%{needle}%"
search_params = [like, like, like, like]
search_sql = (
" AND (ma.original_filename ILIKE %s OR ma.storage_key ILIKE %s"
" OR COALESCE(ma.copyright_notice, '') ILIKE %s"
" OR EXISTS (SELECT 1 FROM unnest(ma.tags) AS t WHERE t::text ILIKE %s))"
)
params: list[Any] = (
[is_adm, profile_id, profile_id]
+ club_sql_params
+ uploaded_params
+ search_params
+ [limit, offset]
)
cur.execute( cur.execute(
f"""SELECT ma.id, ma.mime_type, ma.byte_size, ma.original_filename, ma.visibility, ma.club_id, 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, ma.storage_key, ma.copyright_notice, ma.storage_key, ma.tags,
pr.name AS uploader_name, pr.name AS uploader_name,
pr.email AS uploader_email, pr.email AS uploader_email,
cl.name AS club_name cl.name AS club_name
@ -311,16 +532,27 @@ def list_media_assets(
) )
) )
) )
{club_sql}
{uploaded_sql}
{media_kind_sql}
{search_sql} {search_sql}
ORDER BY ma.updated_at DESC NULLS LAST, ma.created_at DESC ORDER BY ma.updated_at DESC NULLS LAST, ma.created_at DESC
LIMIT %s OFFSET %s""", LIMIT %s OFFSET %s""",
params, params,
) )
rows = [r2d(r) for r in cur.fetchall()] rows = [r2d(r) for r in cur.fetchall()]
show_uploader = is_superadmin(role) or is_platform_admin(role) or bool(admin_club_ids) show_club = sup or is_adm
show_club = is_superadmin(role) or is_platform_admin(role) asset_ids = [int(r["id"]) for r in rows]
usage_map = _usage_for_media_assets(cur, asset_ids)
for r in rows: for r in rows:
r["permissions"] = _item_permissions(r, tenant, admin_club_ids) r["permissions"] = _item_permissions(r, tenant, admin_club_ids)
tid = int(r["id"])
r["usage"] = usage_map.get(tid, {"exercises": [], "training_units": []})
tags_val = r.get("tags")
if tags_val is None:
r["tags"] = []
elif not isinstance(tags_val, list):
r["tags"] = list(tags_val) if tags_val else []
if not show_uploader: if not show_uploader:
r["uploader_name"] = None r["uploader_name"] = None
r["uploader_email"] = None r["uploader_email"] = None
@ -329,16 +561,21 @@ def list_media_assets(
viewer = { viewer = {
"show_uploader_meta": show_uploader, "show_uploader_meta": show_uploader,
"show_club_meta": show_club, "show_club_meta": show_club,
"is_superadmin": is_superadmin(role), "is_superadmin": sup,
"is_platform_admin": is_platform_admin(role), "is_platform_admin": is_adm,
} }
filter_meta = None
if include_filter_meta and show_uploader:
filter_meta = {"uploaders": _fetch_filter_uploaders(cur, is_adm, profile_id)}
return { return {
"items": rows, "items": rows,
"limit": limit, "limit": limit,
"offset": offset, "offset": offset,
"lifecycle": lifecycle.strip().lower(), "lifecycle": lifecycle.strip().lower(),
"media_kind": mk,
"viewer": viewer, "viewer": viewer,
"filter_meta": filter_meta,
} }
@ -415,7 +652,11 @@ def bulk_media_patch(
tenant: TenantContext = Depends(get_tenant_context), tenant: TenantContext = Depends(get_tenant_context),
): ):
raw = body.model_dump(exclude_unset=True) if hasattr(body, "model_dump") else body.dict(exclude_unset=True) raw = body.model_dump(exclude_unset=True) if hasattr(body, "model_dump") else body.dict(exclude_unset=True)
patch_fields = {k: v for k, v in raw.items() if k != "media_asset_ids" and v is not None} patch_fields = {
k: v
for k, v in raw.items()
if k != "media_asset_ids" and (k == "tags" or v is not None)
}
if not patch_fields: if not patch_fields:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren") raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
@ -461,6 +702,9 @@ def bulk_media_patch(
if "original_filename" in patch_fields: if "original_filename" in patch_fields:
sets.append("original_filename = %s") sets.append("original_filename = %s")
vals.append(patch_fields["original_filename"]) vals.append(patch_fields["original_filename"])
if "tags" in patch_fields:
sets.append("tags = %s")
vals.append(_normalize_media_tags(patch_fields["tags"]))
if "visibility" in patch_fields or "club_id" in patch_fields: if "visibility" in patch_fields or "club_id" in patch_fields:
sets.append("visibility = %s") sets.append("visibility = %s")
vals.append(str(eff.get("visibility", asset["visibility"])).strip()) vals.append(str(eff.get("visibility", asset["visibility"])).strip())
@ -529,6 +773,9 @@ def patch_media_asset(
if "original_filename" in data: if "original_filename" in data:
sets.append("original_filename = %s") sets.append("original_filename = %s")
vals.append(data["original_filename"]) vals.append(data["original_filename"])
if "tags" in data:
sets.append("tags = %s")
vals.append(_normalize_media_tags(data["tags"]))
if "visibility" in data or "club_id" in data: if "visibility" in data or "club_id" in data:
sets.append("visibility = %s") sets.append("visibility = %s")
vals.append(str(eff.get("visibility", asset["visibility"])).strip()) vals.append(str(eff.get("visibility", asset["visibility"])).strip())
@ -544,7 +791,7 @@ def patch_media_asset(
conn.commit() conn.commit()
cur.execute( cur.execute(
"""SELECT id, mime_type, byte_size, original_filename, visibility, club_id, """SELECT id, mime_type, byte_size, original_filename, visibility, club_id,
uploaded_by_profile_id, lifecycle_state, created_at, sha256, copyright_notice uploaded_by_profile_id, lifecycle_state, created_at, sha256, copyright_notice, tags
FROM media_assets WHERE id = %s""", FROM media_assets WHERE id = %s""",
(asset_id,), (asset_id,),
) )

View File

@ -47,20 +47,28 @@ def test_list_media_assets_ok_mocked(client: TestClient) -> None:
) )
mock_cur = MagicMock() mock_cur = MagicMock()
mock_cur.fetchall.return_value = [ mock_cur.fetchall.side_effect = [
{ [
"id": 1, {
"mime_type": "image/png", "id": 1,
"byte_size": 100, "mime_type": "image/png",
"original_filename": "a.png", "byte_size": 100,
"visibility": "official", "original_filename": "a.png",
"club_id": None, "visibility": "official",
"uploaded_by_profile_id": 2, "club_id": None,
"lifecycle_state": "active", "uploaded_by_profile_id": 2,
"created_at": None, "lifecycle_state": "active",
"sha256": "a" * 64, "created_at": None,
"copyright_notice": None, "sha256": "a" * 64,
} "copyright_notice": None,
"storage_key": "media/a.png",
"tags": ["demo"],
"uploader_name": None,
"uploader_email": None,
"club_name": None,
}
],
[],
] ]
mock_cm = _mock_db(mock_cur) mock_cm = _mock_db(mock_cur)
@ -74,6 +82,8 @@ def test_list_media_assets_ok_mocked(client: TestClient) -> None:
assert body["limit"] == 30 assert body["limit"] == 30
assert len(body["items"]) == 1 assert len(body["items"]) == 1
assert body["items"][0]["original_filename"] == "a.png" assert body["items"][0]["original_filename"] == "a.png"
assert body["items"][0]["usage"] == {"exercises": [], "training_units": []}
assert body["items"][0]["tags"] == ["demo"]
assert "viewer" in body assert "viewer" in body
@ -377,6 +387,32 @@ def test_list_media_assets_lifecycle_trash_soft_mocked(client: TestClient) -> No
assert list_sql_calls and "trash_soft" in list_sql_calls[0] assert list_sql_calls and "trash_soft" in list_sql_calls[0]
def test_list_media_assets_invalid_media_kind_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?media_kind=movies", headers={"X-Auth-Token": "t"})
assert r.status_code == 400
def test_list_media_assets_club_filter_forbidden_non_superadmin(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "admin"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=10,
global_role="admin",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
r = client.get("/api/media-assets?club_id=3", headers={"X-Auth-Token": "t"})
assert r.status_code == 403
def test_list_media_assets_invalid_lifecycle_400(client: TestClient) -> None: 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[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext( app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
@ -422,6 +458,7 @@ def test_patch_media_asset_copyright_mocked(client: TestClient) -> None:
"created_at": None, "created_at": None,
"sha256": "b" * 64, "sha256": "b" * 64,
"copyright_notice": "© HoldCo", "copyright_notice": "© HoldCo",
"tags": [],
}, },
] ]
mock_cm = _mock_db(mock_cur) mock_cm = _mock_db(mock_cur)

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.49" APP_VERSION = "0.8.50"
BUILD_DATE = "2026-05-07" BUILD_DATE = "2026-05-07"
DB_SCHEMA_VERSION = "20260507045" DB_SCHEMA_VERSION = "20260507046"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute) "auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute)
@ -13,7 +13,7 @@ MODULE_VERSIONS = {
"club_join_requests": "1.0.1", # Depends(get_tenant_context) "club_join_requests": "1.0.1", # Depends(get_tenant_context)
"admin_users": "1.0.0", # GET /api/admin/users "admin_users": "1.0.0", # GET /api/admin/users
"platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT) "platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT)
"media_assets": "1.4.0", # Manager: RBAC trash_soft Trainer nur privat; purge nur Superadmin; Superadmin force/hard-delete; Liste + permissions + JOINs; bulk lifecycle/patch "media_assets": "1.5.0", # tags TEXT[] + erweiterte Suche/Filter; usage Übungen+Einheiten; PATCH/Bulk tags; API media_kind club uploader
"groups": "0.1.0", "groups": "0.1.0",
"skills": "0.1.0", "skills": "0.1.0",
"methods": "0.1.0", "methods": "0.1.0",
@ -29,6 +29,13 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.50",
"date": "2026-05-07",
"changes": [
"Medienbibliothek: DB 046 media_assets.tags (GIN); GET filter media_kind, club_id (Superadmin), uploaded_by; Suche über Copyright & Tags; Antwort usage exercises + training_units; PATCH/Bulk tags; UI volle Breite, Filterleiste, ©-Symbol auf Kacheln, Links zu Übungen/Planung, gleiche Sichtbarkeits-/Status-Symbole wie Übungsliste",
],
},
{ {
"version": "0.8.49", "version": "0.8.49",
"date": "2026-05-07", "date": "2026-05-07",

View File

@ -5461,8 +5461,10 @@ a.analysis-split__nav-item {
max(24px, env(safe-area-inset-bottom, 0px) + 8px) max(16px, env(safe-area-inset-left, 0px)); max(24px, env(safe-area-inset-bottom, 0px) + 8px) max(16px, env(safe-area-inset-left, 0px));
} }
.media-library__container { .media-library__container {
max-width: 1200px; width: 100%;
margin: 0 auto; max-width: none;
margin: 0;
box-sizing: border-box;
} }
.media-library__hero { .media-library__hero {
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
@ -5531,6 +5533,74 @@ a.analysis-split__nav-item {
gap: 12px; gap: 12px;
font-size: 0.875rem; font-size: 0.875rem;
} }
.media-library__filters-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-top: 12px;
}
.media-library__filters-row .form-input,
.media-library__filters-row select.form-input {
min-width: 0;
flex: 1 1 140px;
max-width: 240px;
}
.media-library__card-copyright {
position: absolute;
right: 6px;
bottom: 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-footer-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.media-library__tag-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.media-library__tag-chip {
font-size: 0.65rem;
padding: 2px 6px;
border-radius: 6px;
background: var(--surface2);
color: var(--text2);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-library__usage-links {
font-size: 0.72rem;
line-height: 1.45;
margin-top: 8px;
color: var(--text2);
}
.media-library__usage-links a {
color: var(--accent-dark, #2563eb);
text-decoration: none;
margin-right: 8px;
}
.media-library__usage-links a:hover {
text-decoration: underline;
}
.media-library__check-all { .media-library__check-all {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -5765,9 +5835,10 @@ a.analysis-split__nav-item {
max-width: 160px; max-width: 160px;
} }
@media (max-width: 639px) { @media (max-width: 639px) {
.media-library__table .media-library__td-sub, .media-library__th-tags,
.media-library__table th:nth-child(5), .media-library__td-tags,
.media-library__table td:nth-child(5) { .media-library__th-usage,
.media-library__td-usage {
display: none; display: none;
} }
} }

View File

@ -5,6 +5,14 @@ import {
List, List,
MoreVertical, MoreVertical,
X, X,
Globe,
Users,
Lock,
CheckCircle2,
Archive,
CircleDot,
FilePenLine,
Copyright,
} from 'lucide-react' } from 'lucide-react'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import api from '../utils/api' import api from '../utils/api'
@ -24,11 +32,110 @@ const VIS_OPTIONS = [
{ value: 'official', label: 'Offiziell' }, { value: 'official', label: 'Offiziell' },
] ]
function lcLabel(code) { const MEDIA_KIND_OPTIONS = [
{ value: 'all', label: 'Alle Typen' },
{ value: 'image', label: 'Bild' },
{ value: 'video', label: 'Video' },
{ value: 'pdf', label: 'PDF' },
{ value: 'other', label: 'Sonstiges' },
]
const LC_STATUS_LABELS = {
active: 'Aktiv',
trash_soft: 'Papierkorb (1)',
trash_hidden: 'Ausgeblendet (2)',
}
function visibilityUiLabel(v) {
const o = VIS_OPTIONS.find((x) => x.value === (v || '').toLowerCase())
return o ? o.label : v || '—'
}
function MediaCardScopeStatus({ visibility, lifecycleState }) {
const v = (visibility || 'private').toLowerCase()
const lc = (lifecycleState || 'active').toLowerCase()
const visLabel = visibilityUiLabel(v)
const lcLabel = LC_STATUS_LABELS[lc] || lcLabelFromOptions(lc)
const tip = `${visLabel} · ${lcLabel}`
let VisIcon = Lock
if (v === 'official') VisIcon = Globe
else if (v === 'club') VisIcon = Users
let LcIcon = FilePenLine
if (lc === 'active') LcIcon = CheckCircle2
else if (lc === 'archived' || lc === 'trash_hidden') LcIcon = Archive
else if (lc === 'in_review' || lc === 'trash_soft') LcIcon = CircleDot
return (
<div
className="exercise-card__meta-compact"
title={tip}
aria-label={`Sichtbarkeit: ${visLabel}. Lebenszyklus: ${lcLabel}.`}
>
<span className="exercise-card__meta-glyph">
<VisIcon size={15} strokeWidth={2} aria-hidden />
</span>
<span className="exercise-card__meta-sep" aria-hidden>
·
</span>
<span className="exercise-card__meta-glyph">
<LcIcon size={15} strokeWidth={2} aria-hidden />
</span>
</div>
)
}
function lcLabelFromOptions(code) {
const o = LC_OPTIONS.find((x) => x.value === code) const o = LC_OPTIONS.find((x) => x.value === code)
return o ? o.label : code return o ? o.label : code
} }
function lcLabel(code) {
return lcLabelFromOptions(code)
}
function parseTagsInput(s) {
return String(s || '')
.split(/[,;\n]+/)
.map((x) => x.trim())
.filter(Boolean)
}
function MediaUsageBlock({ usage, compact }) {
const u = usage || { exercises: [], training_units: [] }
const ex = u.exercises || []
const tus = u.training_units || []
if (!ex.length && !tus.length)
return <span className="media-library__hint">{compact ? '—' : 'Noch in keiner Übung / Einheit verknüpft.'}</span>
return (
<div className="media-library__usage-links">
{ex.length ? (
<div>
<strong>Übungen</strong>{' '}
{ex.map((e) => (
<Link key={e.id} to={`/exercises/${e.id}`} title={e.title}>
{e.title.length > (compact ? 18 : 40) ? `${e.title.slice(0, compact ? 18 : 40)}` : e.title}
</Link>
))}
</div>
) : null}
{tus.length ? (
<div style={{ marginTop: ex.length ? 6 : 0 }}>
<strong>Trainings­einheiten</strong>{' '}
{tus.map((t) => {
const label =
[t.planned_date, (t.group_name || '').trim()].filter(Boolean).join(' · ') || `Einheit #${t.id}`
const short = label.length > (compact ? 20 : 36) ? `${label.slice(0, compact ? 20 : 36)}` : label
return (
<Link key={t.id} to={`/planning?unit=${t.id}`} title={label}>
{short}
</Link>
)
})}
</div>
) : null}
</div>
)
}
function uploaderLabel(it, viewer) { function uploaderLabel(it, viewer) {
if (!viewer?.show_uploader_meta) return null if (!viewer?.show_uploader_meta) return null
const n = (it.uploader_name || '').trim() const n = (it.uploader_name || '').trim()
@ -111,6 +218,10 @@ export default function MediaLibraryPage() {
const [bulkApplyVis, setBulkApplyVis] = useState(false) const [bulkApplyVis, setBulkApplyVis] = useState(false)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [preview, setPreview] = useState(null) const [preview, setPreview] = useState(null)
const [mediaKind, setMediaKind] = useState('all')
const [filterClubId, setFilterClubId] = useState('')
const [filterUploaderId, setFilterUploaderId] = useState('')
const [uploaderFilterOptions, setUploaderFilterOptions] = useState([])
const loadClubs = useCallback(async () => { const loadClubs = useCallback(async () => {
try { try {
@ -134,23 +245,34 @@ export default function MediaLibraryPage() {
q: q.trim(), q: q.trim(),
limit: 100, limit: 100,
offset: 0, offset: 0,
media_kind: mediaKind,
include_filter_meta: true,
...(isSuperadmin && filterClubId
? { club_id: Number(filterClubId) }
: {}),
...(filterUploaderId && viewer?.show_uploader_meta
? { uploaded_by: Number(filterUploaderId) }
: {}),
}) })
setItems(res.items || []) setItems(res.items || [])
setViewer(res.viewer || null) setViewer(res.viewer || null)
if (res.filter_meta?.uploaders?.length) {
setUploaderFilterOptions(res.filter_meta.uploaders)
}
setSelected(new Set()) setSelected(new Set())
} catch (e) { } catch (e) {
setError(e.message || String(e)) setError(e.message || String(e))
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [lifecycle, q]) }, [lifecycle, q, mediaKind, filterClubId, filterUploaderId, isSuperadmin, viewer?.show_uploader_meta])
useEffect(() => { useEffect(() => {
const t = setTimeout(() => { const t = setTimeout(() => {
loadItems() loadItems()
}, 320) }, 320)
return () => clearTimeout(t) return () => clearTimeout(t)
}, [lifecycle, q, loadItems]) }, [lifecycle, q, mediaKind, filterClubId, filterUploaderId, loadItems])
useEffect(() => { useEffect(() => {
if (!preview) return if (!preview) return
@ -182,6 +304,7 @@ export default function MediaLibraryPage() {
setModalDraft({ setModalDraft({
display_name: it.original_filename != null ? String(it.original_filename) : '', display_name: it.original_filename != null ? String(it.original_filename) : '',
copyright_notice: it.copyright_notice != null ? String(it.copyright_notice) : '', copyright_notice: it.copyright_notice != null ? String(it.copyright_notice) : '',
tags_input: Array.isArray(it.tags) ? it.tags.join(', ') : '',
visibility: (it.visibility || 'private').toLowerCase(), visibility: (it.visibility || 'private').toLowerCase(),
club_id: it.club_id != null ? String(it.club_id) : '', club_id: it.club_id != null ? String(it.club_id) : '',
superTarget: 'active', superTarget: 'active',
@ -202,6 +325,7 @@ export default function MediaLibraryPage() {
if (p.edit_metadata) { if (p.edit_metadata) {
body.original_filename = modalDraft.display_name body.original_filename = modalDraft.display_name
body.copyright_notice = modalDraft.copyright_notice body.copyright_notice = modalDraft.copyright_notice
body.tags = parseTagsInput(modalDraft.tags_input)
} }
if (p.change_visibility) { if (p.change_visibility) {
body.visibility = modalDraft.visibility body.visibility = modalDraft.visibility
@ -318,8 +442,9 @@ export default function MediaLibraryPage() {
</div> </div>
</div> </div>
<p className="media-library__intro"> <p className="media-library__intro">
Veröffentlichte Medien (Verein/Plattform) und eigene Uploads. Vorschau: Bild oder Video groß Veröffentlichte Medien (Verein/Plattform) und eigene Uploads. Suche durchsucht Bezeichner,
anklicken. Bearbeiten und Papierkorb über das Menü pro Medium Bulk unten in der Leiste. technischen Speicherpfad, Copyright-Text und Schlagwörter. Vorschau: Vorschaubild antippen.
Bearbeiten über das Menü Bulk in der unteren Leiste.
</p> </p>
</header> </header>
@ -328,7 +453,7 @@ export default function MediaLibraryPage() {
<input <input
type="search" type="search"
className="form-input media-library__search" className="form-input media-library__search"
placeholder="Suche Dateiname …" placeholder="Suche Bezeichner, Pfade, Copyright, Tags …"
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
aria-label="Suche" aria-label="Suche"
@ -372,6 +497,50 @@ export default function MediaLibraryPage() {
Aktualisieren Aktualisieren
</button> </button>
</div> </div>
<div className="media-library__filters-row" aria-label="Filter">
<select
className="form-input"
value={mediaKind}
onChange={(e) => setMediaKind(e.target.value)}
aria-label="Medientyp"
>
{MEDIA_KIND_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{isSuperadmin ? (
<select
className="form-input"
value={filterClubId}
onChange={(e) => setFilterClubId(e.target.value)}
aria-label="Verein (Filter)"
>
<option value="">Alle Vereine</option>
{clubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || c.id}
</option>
))}
</select>
) : null}
{viewer?.show_uploader_meta ? (
<select
className="form-input"
value={filterUploaderId}
onChange={(e) => setFilterUploaderId(e.target.value)}
aria-label="Uploader"
>
<option value="">Alle Uploader</option>
{uploaderFilterOptions.map((u) => (
<option key={u.id} value={String(u.id)}>
{u.label}
</option>
))}
</select>
) : null}
</div>
<div className="media-library__toolbar-meta"> <div className="media-library__toolbar-meta">
<label className="media-library__check-all"> <label className="media-library__check-all">
<input type="checkbox" checked={items.length > 0 && selCount === items.length} onChange={selectAll} /> <input type="checkbox" checked={items.length > 0 && selCount === items.length} onChange={selectAll} />
@ -400,7 +569,6 @@ export default function MediaLibraryPage() {
{viewMode === 'grid' && items.length > 0 ? ( {viewMode === 'grid' && items.length > 0 ? (
<div className="media-library__grid"> <div className="media-library__grid">
{items.map((it) => { {items.map((it) => {
const lc = (it.lifecycle_state || '').toLowerCase()
const chk = selected.has(it.id) const chk = selected.has(it.id)
return ( return (
<div key={it.id} className="media-library__card"> <div key={it.id} className="media-library__card">
@ -424,16 +592,34 @@ export default function MediaLibraryPage() {
> >
<div className="media-library__card-thumb-wrap"> <div className="media-library__card-thumb-wrap">
<MediaThumb mediaId={it.id} mimeType={it.mime_type} /> <MediaThumb mediaId={it.id} mimeType={it.mime_type} />
{(it.copyright_notice || '').trim() ? (
<span
className="media-library__card-copyright"
title={(it.copyright_notice || '').trim()}
aria-label="Copyright-Eintrag vorhanden"
>
<Copyright size={14} strokeWidth={2} aria-hidden />
</span>
) : null}
</div> </div>
</button> </button>
<div className="media-library__card-footer"> <div className="media-library__card-footer">
<div className="media-library__card-name" title={it.original_filename || `#${it.id}`}> <div className="media-library__card-name" title={it.original_filename || `#${it.id}`}>
{it.original_filename || `Medium #${it.id}`} {it.original_filename || `Medium #${it.id}`}
</div> </div>
<div className="media-library__card-tags"> <div className="media-library__card-footer-row">
<span>{it.visibility}</span> <MediaCardScopeStatus visibility={it.visibility} lifecycleState={it.lifecycle_state} />
<span>{lcLabel(lc)}</span>
</div> </div>
{(it.tags || []).length ? (
<div className="media-library__tag-chips">
{(it.tags || []).slice(0, 6).map((tg) => (
<span key={tg} className="media-library__tag-chip">
{tg}
</span>
))}
</div>
) : null}
<MediaUsageBlock usage={it.usage} compact />
</div> </div>
</div> </div>
) )
@ -449,8 +635,9 @@ export default function MediaLibraryPage() {
<th className="media-library__th-check" /> <th className="media-library__th-check" />
<th>Vorschau</th> <th>Vorschau</th>
<th>Bezeichnung</th> <th>Bezeichnung</th>
<th>Sichtbarkeit</th> <th>Kennzeichen</th>
<th>Status</th> <th className="media-library__th-tags">Tags</th>
<th className="media-library__th-usage">Verwendung</th>
{viewer?.show_club_meta ? <th>Verein</th> : null} {viewer?.show_club_meta ? <th>Verein</th> : null}
{viewer?.show_uploader_meta ? <th>Uploader</th> : null} {viewer?.show_uploader_meta ? <th>Uploader</th> : null}
<th className="media-library__th-act" /> <th className="media-library__th-act" />
@ -458,7 +645,6 @@ export default function MediaLibraryPage() {
</thead> </thead>
<tbody> <tbody>
{items.map((it) => { {items.map((it) => {
const lc = (it.lifecycle_state || '').toLowerCase()
return ( return (
<tr key={it.id}> <tr key={it.id}>
<td> <td>
@ -477,8 +663,15 @@ export default function MediaLibraryPage() {
</button> </button>
</td> </td>
<td className="media-library__td-name">{it.original_filename || `#${it.id}`}</td> <td className="media-library__td-name">{it.original_filename || `#${it.id}`}</td>
<td>{it.visibility}</td> <td className="media-library__td-glyphs">
<td>{lcLabel(lc)}</td> <MediaCardScopeStatus visibility={it.visibility} lifecycleState={it.lifecycle_state} />
</td>
<td className="media-library__td-tags media-library__td-sub">
{(it.tags || []).length ? (it.tags || []).join(', ') : '—'}
</td>
<td className="media-library__td-usage media-library__td-sub">
<MediaUsageBlock usage={it.usage} compact />
</td>
{viewer?.show_club_meta ? ( {viewer?.show_club_meta ? (
<td className="media-library__td-sub">{it.club_name || it.club_id || '—'}</td> <td className="media-library__td-sub">{it.club_name || it.club_id || '—'}</td>
) : null} ) : null}
@ -727,6 +920,13 @@ export default function MediaLibraryPage() {
value={modalDraft.copyright_notice} value={modalDraft.copyright_notice}
onChange={(e) => setModalDraft((d) => ({ ...d, copyright_notice: e.target.value }))} onChange={(e) => setModalDraft((d) => ({ ...d, copyright_notice: e.target.value }))}
/> />
<label className="form-label">Schlagwörter (kommagetrennt)</label>
<input
className="form-input"
value={modalDraft.tags_input}
onChange={(e) => setModalDraft((d) => ({ ...d, tags_input: e.target.value }))}
placeholder="z. B. Technik, Wurf"
/>
</> </>
) : ( ) : (
<p className="media-library__hint">Keine Berechtigung für Metadaten nur Verwaltende dieser Stufe.</p> <p className="media-library__hint">Keine Berechtigung für Metadaten nur Verwaltende dieser Stufe.</p>
@ -766,6 +966,20 @@ export default function MediaLibraryPage() {
</> </>
) : null} ) : null}
<div className="media-library__meta-block">
<span className="media-library__meta-k">Sichtbarkeit / Lebenszyklus</span>
<div className="media-library__meta-v" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<MediaCardScopeStatus visibility={modal.visibility} lifecycleState={modal.lifecycle_state} />
</div>
</div>
<div className="media-library__meta-block">
<span className="media-library__meta-k">Verwendung</span>
<div className="media-library__meta-v">
<MediaUsageBlock usage={modal.usage} compact={false} />
</div>
</div>
<div className="media-library__modal-actions"> <div className="media-library__modal-actions">
<button type="button" className="btn btn-secondary" disabled={busy} onClick={saveModal}> <button type="button" className="btn btn-secondary" disabled={busy} onClick={saveModal}>
Speichern Speichern

View File

@ -535,6 +535,10 @@ export async function listMediaAssets(params = {}) {
if (params.limit != null) sp.set('limit', String(params.limit)) if (params.limit != null) sp.set('limit', String(params.limit))
if (params.offset != null) sp.set('offset', String(params.offset)) if (params.offset != null) sp.set('offset', String(params.offset))
if (params.lifecycle) sp.set('lifecycle', String(params.lifecycle)) if (params.lifecycle) sp.set('lifecycle', String(params.lifecycle))
if (params.media_kind) sp.set('media_kind', String(params.media_kind))
if (params.club_id != null && params.club_id !== '') sp.set('club_id', String(params.club_id))
if (params.uploaded_by != null && params.uploaded_by !== '') sp.set('uploaded_by', String(params.uploaded_by))
if (params.include_filter_meta) sp.set('include_filter_meta', 'true')
const qs = sp.toString() const qs = sp.toString()
return request(`/api/media-assets${qs ? `?${qs}` : ''}`) return request(`/api/media-assets${qs ? `?${qs}` : ''}`)
} }