MediaPfad extern, Upload Manager Bug Fixes #23

Merged
Lars merged 21 commits from develop into main 2026-05-08 11:17:12 +02:00
12 changed files with 259 additions and 38 deletions
Showing only changes of commit f354bd9f77 - Show all commits

View File

@ -157,13 +157,13 @@ def assert_valid_governance_visibility(
visibility: str, visibility: str,
club_id: Optional[int], club_id: Optional[int],
) -> None: ) -> None:
"""Pflicht club_id bei visibility=club; Mitgliedschaft außer Plattform-Admin; official nur Plattform-Admin.""" """Pflicht club_id bei visibility=club; Mitgliedschaft außer Plattform-Admin; official nur Superadmin."""
if visibility not in _GOVERNANCE_VISIBILITY: if visibility not in _GOVERNANCE_VISIBILITY:
raise HTTPException(status_code=400, detail="Ungültige visibility") raise HTTPException(status_code=400, detail="Ungültige visibility")
if visibility == "official" and not is_platform_admin(role): if visibility == "official" and not is_superadmin(role):
raise HTTPException( raise HTTPException(
status_code=403, status_code=403,
detail="Nur Plattform-Admins dürfen offizielle Inhalte setzen", detail="Nur Superadmins dürfen offizielle Inhalte setzen",
) )
if visibility == "club": if visibility == "club":
if club_id is None: if club_id is None:

View File

@ -150,13 +150,15 @@ def library_storage_key(
- official library/official/{kind}/{sha256}{ext} - official library/official/{kind}/{sha256}{ext}
- club (vereinsgeteilt) library/{vereins-segment}/{kind}/{sha256}{ext} - club (vereinsgeteilt) library/{vereins-segment}/{kind}/{sha256}{ext}
- private (nur Hochlader) library/{vereins-segment}/u{profile}/{kind}/{sha256}{ext} - private dieselbe Ordnerlogik wie Verein: library/{vereins-segment}/{kind}/{sha}.u{profile}{ext}
Dateiname bei private: {sha}.u{profile_id}{ext} (nicht Unterordner u{}), damit Ordnerstruktur wie bei Verein.
Vereins-Segment: aus club_name abgeleitet + -c{club_id} siehe library_club_path_segment. Vereins-Segment: aus club_name abgeleitet + -c{club_id} siehe library_club_path_segment.
kind {image, video, pdf, other} siehe library_media_kind_dir. kind {image, video, pdf, other} siehe library_media_kind_dir.
Kein Ordnername private auf der Platte: Zuordnung erfolgt über Uploader unter dem Verein. Kein Ordnername private auf der Platte. Private Dateien unterscheiden sich nur im Dateinamen (.u{Profil} vor Endung).
""" """
vis = (visibility or "private").strip().lower() vis = (visibility or "private").strip().lower()
if vis not in ("private", "club", "official"): if vis not in ("private", "club", "official"):
@ -176,9 +178,9 @@ def library_storage_key(
kind = library_media_kind_dir(mime_type, e) kind = library_media_kind_dir(mime_type, e)
e = e[:16] e = e[:16]
blob = f"{kind}/{sha}{e}" club_blob = f"{kind}/{sha}{e}"
if vis == "official": if vis == "official":
return f"library/official/{blob}" return f"library/official/{club_blob}"
if club_id is None: if club_id is None:
raise ValueError("Verein (club_id) ist für diese Sichtbarkeit auf der Platte erforderlich") raise ValueError("Verein (club_id) ist für diese Sichtbarkeit auf der Platte erforderlich")
cid = int(club_id) cid = int(club_id)
@ -186,13 +188,14 @@ def library_storage_key(
raise ValueError("club_id muss eine positive Ganzzahl sein") raise ValueError("club_id muss eine positive Ganzzahl sein")
club_seg = library_club_path_segment(cid, club_name) club_seg = library_club_path_segment(cid, club_name)
if vis == "club": if vis == "club":
return f"library/{club_seg}/{blob}" return f"library/{club_seg}/{club_blob}"
if uploader_profile_id is None: if uploader_profile_id is None:
raise ValueError("uploader_profile_id ist für private Medien erforderlich (Ordner u{…} unter dem Verein)") raise ValueError("uploader_profile_id ist für private Archiv-Medien erforderlich")
up = int(uploader_profile_id) up = int(uploader_profile_id)
if up < 1: if up < 1:
raise ValueError("uploader_profile_id muss positiv sein") raise ValueError("uploader_profile_id muss positiv sein")
return f"library/{club_seg}/u{up}/{blob}" priv_name = f"{sha}.u{up}{e}"
return f"library/{club_seg}/{kind}/{priv_name}"
def relocate_local_media_file(media_root: Path, old_storage_key: str, new_storage_key: str) -> None: def relocate_local_media_file(media_root: Path, old_storage_key: str, new_storage_key: str) -> None:

View File

@ -642,6 +642,72 @@ def apply_official_exercise_media_rules(
) )
def apply_club_exercise_media_copyright_rules(cur, exercise_id: int, next_visibility: str) -> None:
"""
Vereins-sichtbare Übung: angehängte Archiv-Dateien müssen aktiv sein und einen Copyright-Vermerk haben
(wie bei offiziellen Übungen, ohne Sichtbarkeits-Promotion der Assets).
"""
nv = (next_visibility or "private").strip().lower()
if nv != "club":
return
rows = _fetch_exercise_linked_file_assets(cur, exercise_id)
if not rows:
return
blocking_lc: List[Dict[str, Any]] = []
missing_cr: List[Dict[str, Any]] = []
for r in rows:
aid = int(r["id"])
lc = (r.get("lifecycle_state") or "").strip().lower()
cr = _normalize_media_copyright_notice(r.get("copyright_notice"))
if lc != "active":
blocking_lc.append(
{
"media_asset_id": aid,
"lifecycle_state": lc,
"visibility": (r.get("visibility") or "").strip().lower(),
"original_filename": r.get("original_filename"),
}
)
continue
if len(cr) < _MIN_OFFICIAL_MEDIA_COPYRIGHT_LEN:
missing_cr.append(
{
"media_asset_id": aid,
"original_filename": r.get("original_filename"),
}
)
if blocking_lc:
raise HTTPException(
status_code=422,
detail={
"code": "CLUB_MEDIA_LIFECYCLE",
"message": (
"Nicht aktive Archiv-Medien dürfen nicht an einer vereinsöffentlichen Übung hängen "
"(Papierkorb/Recovery zuerst)."
),
"media_assets": blocking_lc,
},
)
if missing_cr:
raise HTTPException(
status_code=422,
detail={
"code": "CLUB_MEDIA_COPYRIGHT_REQUIRED",
"message": (
f"Für vereinsöffentliche Übungen ist ein Copyright-Vermerk pro Datei erforderlich "
f"(mind. {_MIN_OFFICIAL_MEDIA_COPYRIGHT_LEN} Zeichen)."
),
"media_assets": missing_cr,
},
)
def _abs_media_path(file_path_db: str, media_root: Path) -> Optional[Path]: def _abs_media_path(file_path_db: str, media_root: Path) -> Optional[Path]:
if not file_path_db or file_path_db.startswith("http"): if not file_path_db or file_path_db.startswith("http"):
return None return None
@ -1247,6 +1313,23 @@ def bulk_patch_exercises_metadata(
failed.append(entry) failed.append(entry)
continue continue
if (next_vis or "").strip().lower() == "club":
try:
apply_club_exercise_media_copyright_rules(cur, ex_id, next_vis)
except HTTPException as he:
d = he.detail
entry: Dict[str, Any] = {"id": ex_id}
if isinstance(d, dict):
entry["detail"] = str(d.get("message") or d.get("code") or "Vereins-Medien-Validierung fehlgeschlagen")
if "code" in d:
entry["code"] = d["code"]
if "media_assets" in d:
entry["media_assets"] = d["media_assets"]
else:
entry["detail"] = _fail_msg(he)
failed.append(entry)
continue
sets: List[str] = [] sets: List[str] = []
vals: List[Any] = [] vals: List[Any] = []
if patch_visibility: if patch_visibility:
@ -1757,13 +1840,13 @@ def create_exercise(
) )
row = cur.fetchone() row = cur.fetchone()
exercise_id = row['id'] if isinstance(row, dict) else row[0] exercise_id = row['id'] if isinstance(row, dict) else row[0]
data = body.dict()
assign_exercise_relations(cur, conn, exercise_id, data, do_commit=False)
if (body.visibility or "").strip().lower() == "club":
apply_club_exercise_media_copyright_rules(cur, exercise_id, "club")
conn.commit() conn.commit()
# M:N Relations zuweisen
data = body.dict()
assign_exercise_relations(cur, conn, exercise_id, data)
# Vollständiges Objekt zurückgeben
exercise = enrich_exercise_detail(exercise_id, cur) exercise = enrich_exercise_detail(exercise_id, cur)
return exercise return exercise
@ -1866,6 +1949,11 @@ def update_exercise(
cur.execute(query, params) cur.execute(query, params)
assign_exercise_relations(cur, conn, exercise_id, data, do_commit=False) assign_exercise_relations(cur, conn, exercise_id, data, do_commit=False)
try:
apply_club_exercise_media_copyright_rules(cur, exercise_id, next_vis)
except HTTPException:
conn.rollback()
raise
conn.commit() conn.commit()
exercise = enrich_exercise_detail(exercise_id, cur) exercise = enrich_exercise_detail(exercise_id, cur)

View File

@ -550,6 +550,14 @@ def _ingest_library_media_file(
elif vis == "private": elif vis == "private":
if club_id_form is not None: if club_id_form is not None:
next_cid = int(club_id_form) next_cid = int(club_id_form)
elif is_platform_admin(role):
raise HTTPException(
status_code=400,
detail=(
"Private Archiv-Uploads als Plattform-Admin: bitte den Zielverein wählen und "
"club_id im Formular setzen (nicht vom allgemeinen Kontext ableiten)."
),
)
else: else:
cid = tenant.effective_club_id cid = tenant.effective_club_id
next_cid = int(cid) if cid is not None else None next_cid = int(cid) if cid is not None else None

View File

@ -0,0 +1,62 @@
"""Vereins-Übung: Copyright-Pflicht für angehängte Archiv-Dateien."""
from __future__ import annotations
import pytest
from fastapi import HTTPException
import routers.exercises as exercises_mod
from routers.exercises import apply_club_exercise_media_copyright_rules
def test_apply_club_exercise_media_copyright_skips_non_club() -> None:
apply_club_exercise_media_copyright_rules(object(), 1, "private")
def test_apply_club_exercise_media_copyright_missing_copyright() -> None:
orig = exercises_mod._fetch_exercise_linked_file_assets
def mock_fetch(_cur, eid: int):
assert eid == 42
return [
{
"id": 10,
"visibility": "private",
"club_id": 1,
"lifecycle_state": "active",
"copyright_notice": "",
"original_filename": "x.jpg",
}
]
exercises_mod._fetch_exercise_linked_file_assets = mock_fetch
try:
with pytest.raises(HTTPException) as ei:
apply_club_exercise_media_copyright_rules(object(), 42, "club")
assert ei.value.status_code == 422
d = ei.value.detail
assert isinstance(d, dict)
assert d.get("code") == "CLUB_MEDIA_COPYRIGHT_REQUIRED"
finally:
exercises_mod._fetch_exercise_linked_file_assets = orig
def test_apply_club_exercise_media_copyright_ok() -> None:
orig = exercises_mod._fetch_exercise_linked_file_assets
def mock_fetch(_cur, _eid: int):
return [
{
"id": 10,
"visibility": "private",
"club_id": 1,
"lifecycle_state": "active",
"copyright_notice": "© Verein 2026",
"original_filename": "x.jpg",
}
]
exercises_mod._fetch_exercise_linked_file_assets = mock_fetch
try:
apply_club_exercise_media_copyright_rules(object(), 42, "club")
finally:
exercises_mod._fetch_exercise_linked_file_assets = orig

View File

@ -31,7 +31,7 @@ def test_library_storage_key_private_is_uploader_under_club() -> None:
library_storage_key( library_storage_key(
"private", 7, _HEX64, ".jpg", uploader_profile_id=99, club_name="Ost Dojo München" "private", 7, _HEX64, ".jpg", uploader_profile_id=99, club_name="Ost Dojo München"
) )
== f"library/ost-dojo-muenchen-c7/u99/image/{_HEX64}.jpg" == f"library/ost-dojo-muenchen-c7/image/{_HEX64}.u99.jpg"
) )
@ -103,5 +103,5 @@ def test_library_storage_key_extension() -> None:
def test_library_storage_key_private_no_club_name_fallback() -> None: def test_library_storage_key_private_no_club_name_fallback() -> None:
assert ( assert (
library_storage_key("private", 2, _HEX64, ".jpg", uploader_profile_id=3) library_storage_key("private", 2, _HEX64, ".jpg", uploader_profile_id=3)
== f"library/verein-c2/u3/image/{_HEX64}.jpg" == f"library/verein-c2/image/{_HEX64}.u3.jpg"
) )

View File

@ -18,7 +18,7 @@ from tenant_context import TenantContext, get_tenant_context
# Gültige storage_key-Beispiele (64 Hex-Zeichen wie echter SHA-256) # Gültige storage_key-Beispiele (64 Hex-Zeichen wie echter SHA-256)
_SK_OFF_A = f"library/official/image/{'a' * 64}.jpg" _SK_OFF_A = f"library/official/image/{'a' * 64}.jpg"
_SK_OFF_B = f"library/official/image/{'b' * 64}.jpg" _SK_OFF_B = f"library/official/image/{'b' * 64}.jpg"
_SK_PRIV_C = f"library/verein-c1/u1/video/{'c' * 64}.mp4" _SK_PRIV_C = f"library/verein-c1/video/{'c' * 64}.u1.mp4"
@pytest.fixture @pytest.fixture

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.56" APP_VERSION = "0.8.57"
BUILD_DATE = "2026-05-07" BUILD_DATE = "2026-05-07"
DB_SCHEMA_VERSION = "20260508049" DB_SCHEMA_VERSION = "20260508049"
@ -13,11 +13,11 @@ 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.11.0", # library/{vereins-slug}-c{id}/ statt library/club/c{id}/ "media_assets": "1.12.0", # Privat: Plattform-Admin muss club_id; private Ablage wie Verein (.u{pid} im Dateinamen)
"groups": "0.1.0", "groups": "0.1.0",
"skills": "0.1.0", "skills": "0.1.0",
"methods": "0.1.0", "methods": "0.1.0",
"exercises": "2.17.4", # Übungs-Upload: Vereinsnamen für library-Pfad aus clubs "exercises": "2.18.0", # Vereins-Übung: Copyright-Pflicht File-Assets; official nur Superadmin (Governance)
"training_units": "0.2.0", "training_units": "0.2.0",
"training_programs": "0.1.0", "training_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile "planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
@ -29,6 +29,16 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.57",
"date": "2026-05-07",
"changes": [
"Governance: visibility=official nur noch Superadmin (nicht Plattform-Admin)",
"Medienarchiv: private Uploads als Plattform-Admin erfordern explizites club_id; gleiche Ordnerstruktur wie „Verein“, private Kopien mit .u{Profil} vor Dateiendung",
"Übung visibility=Verein: angebundene Archiv-Dateien müssen aktiv sein und Copyright (≥3 Zeichen) haben; UI- und API-Fehlercodes CLUB_MEDIA_*",
"Frontend: „Offiziell“ nur Superadmin (Bibliothek, Übungsformular, Bulk-Sichtbarkeit, Progressionsgraphen)",
],
},
{ {
"version": "0.8.56", "version": "0.8.56",
"date": "2026-05-07", "date": "2026-05-07",

View File

@ -5,6 +5,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react' import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import api from '../utils/api' import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import ExercisePickerModal from './ExercisePickerModal' import ExercisePickerModal from './ExercisePickerModal'
const VIS_OPTIONS = [ const VIS_OPTIONS = [
@ -99,6 +100,13 @@ export default function ExerciseProgressionGraphPanel({
anchorExerciseId = null, anchorExerciseId = null,
anchorTitle = null, anchorTitle = null,
}) { }) {
const { user } = useAuth()
const isSuperadmin = user?.role === 'superadmin'
const filteredGraphVisOptions = useMemo(
() => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin),
[isSuperadmin],
)
const [graphs, setGraphs] = useState([]) const [graphs, setGraphs] = useState([])
const [selectedGraphId, setSelectedGraphId] = useState(null) const [selectedGraphId, setSelectedGraphId] = useState(null)
const [edges, setEdges] = useState([]) const [edges, setEdges] = useState([])
@ -566,7 +574,7 @@ export default function ExerciseProgressionGraphPanel({
value={newGraphVisibility} value={newGraphVisibility}
onChange={(e) => setNewGraphVisibility(e.target.value)} onChange={(e) => setNewGraphVisibility(e.target.value)}
> >
{VIS_OPTIONS.map((o) => ( {filteredGraphVisOptions.map((o) => (
<option key={o.value} value={o.value}> <option key={o.value} value={o.value}>
{o.label} {o.label}
</option> </option>
@ -599,7 +607,7 @@ export default function ExerciseProgressionGraphPanel({
<div className="form-row"> <div className="form-row">
<label className="form-label">Sichtbarkeit</label> <label className="form-label">Sichtbarkeit</label>
<select className="form-input" value={metaVisibility} onChange={(e) => setMetaVisibility(e.target.value)}> <select className="form-input" value={metaVisibility} onChange={(e) => setMetaVisibility(e.target.value)}>
{VIS_OPTIONS.map((o) => ( {filteredGraphVisOptions.map((o) => (
<option key={o.value} value={o.value}> <option key={o.value} value={o.value}>
{o.label} {o.label}
</option> </option>

View File

@ -5,6 +5,7 @@ import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../utils/
import RichTextEditor from '../components/RichTextEditor' import RichTextEditor from '../components/RichTextEditor'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel' import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels' import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
import { useAuth } from '../context/AuthContext'
/** Kachelvorschau: Video nutzt ersten Frame (metadata), Bild = img. */ /** Kachelvorschau: Video nutzt ersten Frame (metadata), Bild = img. */
function ExerciseMediaThumbTile({ exerciseId, media, onOpenPreview }) { function ExerciseMediaThumbTile({ exerciseId, media, onOpenPreview }) {
@ -406,6 +407,8 @@ function MultiAssocBlock({ title, rows, setRows, options, idKey, emptyLabel }) {
function ExerciseFormPage() { function ExerciseFormPage() {
const { id: routeId } = useParams() const { id: routeId } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const { user } = useAuth()
const isSuperadmin = user?.role === 'superadmin'
const exerciseId = routeId && !Number.isNaN(parseInt(routeId, 10)) ? parseInt(routeId, 10) : null const exerciseId = routeId && !Number.isNaN(parseInt(routeId, 10)) ? parseInt(routeId, 10) : null
const isEdit = exerciseId != null const isEdit = exerciseId != null
@ -676,6 +679,20 @@ function ExerciseFormPage() {
promote_attached_media_for_official: true, promote_attached_media_for_official: true,
...(miss > 0 ? { default_official_media_copyright: String(defaultCopyright).trim() } : {}), ...(miss > 0 ? { default_official_media_copyright: String(defaultCopyright).trim() } : {}),
}) })
} else if (
firstErr.status === 422 &&
firstErr.code === 'CLUB_MEDIA_COPYRIGHT_REQUIRED' &&
firstErr.payload?.media_assets
) {
alert(
'Vereinsöffentliche Übungen brauchen bei jeder verknüpften Datei einen Copyright-Vermerk (mind. 3 Zeichen). Bitte in der Medienbibliothek oder den Mediendetails nachtragen.',
)
throw firstErr
} else if (firstErr.status === 422 && firstErr.code === 'CLUB_MEDIA_LIFECYCLE') {
alert(
'Speichern nicht möglich: mindestens ein verknüpftes Medium ist nicht aktiv (Papierkorb). Bitte reaktivieren oder entfernen.',
)
throw firstErr
} else { } else {
throw firstErr throw firstErr
} }
@ -1243,7 +1260,7 @@ function ExerciseFormPage() {
> >
<option value="private">Privat</option> <option value="private">Privat</option>
<option value="club">Verein</option> <option value="club">Verein</option>
<option value="official">Offiziell</option> {isSuperadmin ? <option value="official">Offiziell</option> : null}
</select> </select>
</div> </div>
<div className="form-row"> <div className="form-row">

View File

@ -152,6 +152,7 @@ function applyDashboardExerciseListUrl(mergedFromPrefs) {
function ExercisesListPage() { function ExercisesListPage() {
const { user, checkAuth } = useAuth() const { user, checkAuth } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const isSuperadmin = user?.role === 'superadmin'
const [mineOnly, setMineOnly] = useState(() => { const [mineOnly, setMineOnly] = useState(() => {
try { try {
@ -557,9 +558,9 @@ function ExercisesListPage() {
{ id: 'private', label: 'Privat' }, { id: 'private', label: 'Privat' },
{ id: 'club', label: 'Verein' }, { id: 'club', label: 'Verein' },
] ]
if (isPlatformAdmin) base.push({ id: 'official', label: 'Offiziell (global)' }) if (isSuperadmin) base.push({ id: 'official', label: 'Offiziell (global)' })
return base return base
}, [isPlatformAdmin]) }, [isSuperadmin])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false

View File

@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, useRef } from 'react' import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { import {
LayoutGrid, LayoutGrid,
@ -235,6 +235,23 @@ export default function MediaLibraryPage() {
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const isSuperadmin = user?.role === 'superadmin' const isSuperadmin = user?.role === 'superadmin'
const archiveVisOptions = useMemo(
() => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin),
[isSuperadmin],
)
const modalVisibilityOptions = useMemo(() => {
if (!modalDraft) return archiveVisOptions
const o = [...archiveVisOptions]
if (!o.some((x) => x.value === modalDraft.visibility)) {
o.push({
value: modalDraft.visibility,
label: visibilityUiLabel(modalDraft.visibility),
})
}
return o
}, [archiveVisOptions, modalDraft])
const [lifecycle, setLifecycle] = useState('active') const [lifecycle, setLifecycle] = useState('active')
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [items, setItems] = useState([]) const [items, setItems] = useState([])
@ -374,7 +391,7 @@ export default function MediaLibraryPage() {
} }
if (p.change_visibility) { if (p.change_visibility) {
body.visibility = modalDraft.visibility body.visibility = modalDraft.visibility
if (modalDraft.visibility === 'club') { if (modalDraft.visibility === 'club' || (modalDraft.visibility === 'private' && isPlatformAdmin)) {
const cid = Number(modalDraft.club_id) const cid = Number(modalDraft.club_id)
if (!cid) { if (!cid) {
alert('Bitte einen Verein wählen.') alert('Bitte einen Verein wählen.')
@ -421,7 +438,7 @@ export default function MediaLibraryPage() {
} }
if (bulkApplyVis) { if (bulkApplyVis) {
body.visibility = bulkVis body.visibility = bulkVis
if (bulkVis === 'club') { if (bulkVis === 'club' || (bulkVis === 'private' && isPlatformAdmin)) {
const cid = Number(bulkClubId) const cid = Number(bulkClubId)
if (!cid) { if (!cid) {
alert('Bitte einen Verein wählen.') alert('Bitte einen Verein wählen.')
@ -482,12 +499,18 @@ export default function MediaLibraryPage() {
window.alert('Bitte einen Verein für die Sichtbarkeit „Verein“ wählen.') window.alert('Bitte einen Verein für die Sichtbarkeit „Verein“ wählen.')
return return
} }
if (uploadVis === 'private' && isPlatformAdmin && !Number(uploadClubId)) {
window.alert('Als Plattform-Admin: Bitte den Zielverein für private Archiv-Uploads wählen (club_id).')
return
}
setUploadBusy(true) setUploadBusy(true)
setUploadSummary('') setUploadSummary('')
try { try {
const res = await api.bulkUploadMediaAssets(list, { const res = await api.bulkUploadMediaAssets(list, {
visibility: uploadVis, visibility: uploadVis,
...(uploadVis === 'club' ? { club_id: Number(uploadClubId) } : {}), ...((uploadVis === 'club' || (uploadVis === 'private' && isPlatformAdmin)) && Number(uploadClubId)
? { club_id: Number(uploadClubId) }
: {}),
}) })
setUploadSummary( setUploadSummary(
`Archiv-Upload: neu ${res.created_count}, bereits vorhanden ${res.duplicate_count}, fehlgeschlagen ${res.failed_count}. Liste aktualisiert.`, `Archiv-Upload: neu ${res.created_count}, bereits vorhanden ${res.duplicate_count}, fehlgeschlagen ${res.failed_count}. Liste aktualisiert.`,
@ -520,8 +543,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. Suche durchsucht Bezeichner, Veröffentlichte Medien (Verein/Plattform) und eigene Uploads Privat steuert nur, wer das Asset in der
technischen Speicherpfad, Copyright-Text und Schlagwörter. Vorschau: Vorschaubild antippen. Datenbank sieht; der Ablageordner folgt dem gewählten Verein wie bei Verein. Plattform-Admins wählen den
Zielverein bei privatem Archiv-Upload aktiv. Suche durchsucht Bezeichner, Speicherpfad, Copyright und Tags.
Bearbeiten über das Menü Bulk in der unteren Leiste. Bearbeiten über das Menü Bulk in der unteren Leiste.
</p> </p>
</header> </header>
@ -647,13 +671,13 @@ export default function MediaLibraryPage() {
}} }}
aria-label="Sichtbarkeit für neuen Upload" aria-label="Sichtbarkeit für neuen Upload"
> >
{VIS_OPTIONS.map((o) => ( {archiveVisOptions.map((o) => (
<option key={o.value} value={o.value}> <option key={o.value} value={o.value}>
Upload: {o.label} Upload: {o.label}
</option> </option>
))} ))}
</select> </select>
{uploadVis === 'club' ? ( {uploadVis === 'club' || (uploadVis === 'private' && isPlatformAdmin) ? (
<select <select
className="form-input" className="form-input"
value={uploadClubId} value={uploadClubId}
@ -969,13 +993,13 @@ export default function MediaLibraryPage() {
{bulkApplyVis ? ( {bulkApplyVis ? (
<> <>
<select className="form-input" value={bulkVis} onChange={(e) => setBulkVis(e.target.value)}> <select className="form-input" value={bulkVis} onChange={(e) => setBulkVis(e.target.value)}>
{VIS_OPTIONS.map((o) => ( {archiveVisOptions.map((o) => (
<option key={o.value} value={o.value}> <option key={o.value} value={o.value}>
{o.label} {o.label}
</option> </option>
))} ))}
</select> </select>
{bulkVis === 'club' ? ( {bulkVis === 'club' || (bulkVis === 'private' && isPlatformAdmin) ? (
<select className="form-input" value={bulkClubId} onChange={(e) => setBulkClubId(e.target.value)}> <select className="form-input" value={bulkClubId} onChange={(e) => setBulkClubId(e.target.value)}>
<option value=""> Verein </option> <option value=""> Verein </option>
{clubs.map((c) => ( {clubs.map((c) => (
@ -1090,13 +1114,13 @@ export default function MediaLibraryPage() {
value={modalDraft.visibility} value={modalDraft.visibility}
onChange={(e) => setModalDraft((d) => ({ ...d, visibility: e.target.value }))} onChange={(e) => setModalDraft((d) => ({ ...d, visibility: e.target.value }))}
> >
{VIS_OPTIONS.map((o) => ( {modalVisibilityOptions.map((o) => (
<option key={o.value} value={o.value}> <option key={o.value} value={o.value}>
{o.label} {o.label}
</option> </option>
))} ))}
</select> </select>
{modalDraft.visibility === 'club' ? ( {modalDraft.visibility === 'club' || (modalDraft.visibility === 'private' && isPlatformAdmin) ? (
<> <>
<label className="form-label">Verein</label> <label className="form-label">Verein</label>
<select <select