shinkan-jinkendo/frontend/src/pages/MediaLibraryPage.jsx
Lars 4588ef4c7e
Some checks failed
Deploy Development / deploy (push) Failing after 24s
Test Suite / pytest-backend (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Failing after 7s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m22s
Refactor navigation components and enhance return context handling
- Replaced `PageReturnLink` with `PageReturnButton` for consistent back navigation across various pages.
- Updated multiple components, including `ExercisePeekModal`, `PageFormEditorChrome`, and `ExerciseDetailPage`, to utilize the new return context features.
- Enhanced CSS styles for the new return button to improve visual consistency.
- Improved navigation logic in `TrainingFrameworkProgramEditPage` and `TrainingModuleEditPage` to ensure seamless user experience when navigating back to previous locations.
2026-05-20 07:42:46 +02:00

2043 lines
85 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
import { Link } from 'react-router-dom'
import NavStateLink from '../components/NavStateLink'
import { buildMediaLibraryReturnContext } from '../utils/navReturnContext'
import {
LayoutGrid,
List,
MoreVertical,
X,
Globe,
Users,
Lock,
CheckCircle2,
Archive,
CircleDot,
FilePenLine,
Copyright,
Image,
Video,
FileText,
File,
Upload,
ScrollText,
} from 'lucide-react'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
import RightsDeclarationDialog from '../components/RightsDeclarationDialog'
import ReportContentModal from '../components/ReportContentModal'
import MediaPreviewModal from '../components/MediaPreviewModal'
const LC_OPTIONS = [
{ value: 'active', label: 'Aktiv' },
{ value: 'trash_soft', label: 'Papierkorb (1)' },
{ value: 'trash_hidden', label: 'Ausgeblendet (2)' },
{ value: 'all', label: 'Alle' },
]
const VIS_OPTIONS = [
{ value: 'private', label: 'Privat' },
{ value: 'club', label: 'Verein' },
{ value: 'official', label: 'Offiziell' },
]
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)
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, returnContext }) {
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>
const LinkOrNav = returnContext ? NavStateLink : Link
const linkExtra = returnContext ? { returnContext } : {}
return (
<div className="media-library__usage-links">
{ex.length ? (
<div>
<strong>Übungen</strong>{' '}
{ex.map((e) => (
<LinkOrNav key={e.id} to={`/exercises/${e.id}`} title={e.title} {...linkExtra}>
{e.title.length > (compact ? 18 : 40) ? `${e.title.slice(0, compact ? 18 : 40)}` : e.title}
</LinkOrNav>
))}
</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 (
<LinkOrNav key={t.id} to={`/planning/units/${t.id}/edit`} title={label} {...linkExtra}>
{short}
</LinkOrNav>
)
})}
</div>
) : null}
</div>
)
}
function uploaderLabel(it, viewer) {
if (!viewer?.show_uploader_meta) return null
const n = (it.uploader_name || '').trim()
const e = (it.uploader_email || '').trim()
if (n) return n
if (e) return e
return it.uploaded_by_profile_id != null ? `Profil #${it.uploaded_by_profile_id}` : '—'
}
function MediaThumb({ mediaId, mimeType }) {
const url = resolveMediaAssetFileUrl(mediaId)
const mime = (mimeType || '').toLowerCase()
/* iPhone-Fotos: Browser-Vorschau oft nicht nutzbar */
if (mime.includes('heic') || mime.includes('heif')) {
return <div className="media-library__thumb-ph">HEIC</div>
}
if (mime.startsWith('video/')) {
return (
<video
className="media-library__thumb-video"
src={url}
muted
playsInline
preload="metadata"
onLoadedMetadata={(e) => {
const el = e.target
try {
const d = el.duration
el.currentTime = Number.isFinite(d) && d > 0 ? Math.min(0.05, d * 0.01) : 0.05
} catch {
/* ignore */
}
}}
/>
)
}
if (mime.startsWith('image/')) {
return (
<img
className="media-library__thumb-img"
src={url}
alt=""
loading="lazy"
onError={(e) => {
e.target.style.display = 'none'
}}
/>
)
}
if (mime.includes('pdf')) {
return <div className="media-library__thumb-ph">PDF</div>
}
return <div className="media-library__thumb-ph"></div>
}
function previewDisplayKind(mimeType) {
const m = (mimeType || '').toLowerCase()
if (m.startsWith('image/')) return 'image'
if (m.startsWith('video/')) return 'video'
if (m.includes('pdf')) return 'pdf'
return 'other'
}
const MEDIA_KIND_LABELS = {
image: 'Bild',
video: 'Video',
pdf: 'PDF',
other: 'Sonstiges',
}
function MediaTypeGlyph({ mimeType, compact }) {
const kind = previewDisplayKind(mimeType)
const label = MEDIA_KIND_LABELS[kind] || 'Medium'
let Icon = File
if (kind === 'image') Icon = Image
else if (kind === 'video') Icon = Video
else if (kind === 'pdf') Icon = FileText
const sz = compact ? 12 : 14
return (
<span
className={`media-library__card-type${compact ? ' media-library__card-type--compact' : ' media-library__card-type--thumb-bl'}`}
title={label}
aria-label={`Medientyp: ${label}`}
>
<Icon size={sz} strokeWidth={2} aria-hidden />
</span>
)
}
function actionTypeLabel(at) {
const MAP = {
upload: 'Erstupload',
promote_club: 'Promotion → Verein',
promote_official: 'Promotion → Offiziell',
re_declaration: 'Nachdeklaration',
legacy_re_declaration: 'Altbestand nachdeklariert',
correction: 'Korrektur',
}
return MAP[at] || at
}
function eventTypeLabel(et) {
const MAP = {
visibility_change: 'Sichtbarkeit geändert',
copyright_change: 'Copyright geändert',
metadata_change: 'Metadaten geändert',
lifecycle_change: 'Lifecycle geändert',
legal_hold_set: 'Sofortsperre gesetzt',
legal_hold_released: 'Sofortsperre aufgehoben',
content_report_filed: 'Inhaltsmeldung',
}
return MAP[et] || et
}
const LEGAL_HOLD_REASON_LABELS = {
rights_dispute: 'Rechtsstreit',
consent_withdrawn: 'Einwilligung widerrufen',
privacy_complaint: 'Datenschutzbeschwerde',
copyright_complaint: 'Urheberrechtsbeschwerde',
youth_protection: 'Jugendschutz',
illegal_content: 'Illegaler Inhalt',
other: 'Sonstige',
}
const LEGAL_HOLD_REASON_CODES = Object.keys(LEGAL_HOLD_REASON_LABELS)
function visLabel(v) {
if (v === 'private') return 'Privat'
if (v === 'club') return 'Verein'
if (v === 'official') return 'Offiziell'
return v || '—'
}
function JournalBoolField({ label, val, context }) {
if (val === null || val === undefined) return null
return (
<div className="media-library__journal-field">
<span className="media-library__journal-field-label">{label}</span>
<span className={`media-library__journal-field-val${val ? ' media-library__journal-field-val--yes' : ' media-library__journal-field-val--no'}`}>
{val ? 'Ja' : 'Nein'}
</span>
{context ? <span className="media-library__journal-field-context">{context}"</span> : null}
</div>
)
}
export default function MediaLibraryPage() {
const { user } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const isSuperadmin = user?.role === 'superadmin'
const hasClubOrgAdmin = activeClubMemberships(user?.clubs).some((c) => (c.roles || []).includes('club_admin'))
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const mediaLibraryReturn = useMemo(() => buildMediaLibraryReturnContext(), [])
const archiveVisOptions = useMemo(
() => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin),
[isSuperadmin],
)
const [lifecycle, setLifecycle] = useState('active')
const [q, setQ] = useState('')
const [items, setItems] = useState([])
const [viewer, setViewer] = useState(null)
const [clubs, setClubs] = useState([])
const [viewMode, setViewMode] = useState('grid')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [selected, setSelected] = useState(() => new Set())
const [modal, setModal] = useState(null)
const [modalDraft, setModalDraft] = useState(null)
const [bulkOpen, setBulkOpen] = useState(false)
const [bulkCopyright, setBulkCopyright] = useState('')
const [bulkVis, setBulkVis] = useState('private')
const [bulkClubId, setBulkClubId] = useState('')
const [bulkApplyVis, setBulkApplyVis] = useState(false)
const [busy, setBusy] = useState(false)
const [preview, setPreview] = useState(null)
const [reportTarget, setReportTarget] = useState(null)
const [mediaKind, setMediaKind] = useState('all')
const [filterClubId, setFilterClubId] = useState('')
const [filterUploaderId, setFilterUploaderId] = useState('')
const [uploaderFilterOptions, setUploaderFilterOptions] = useState([])
const bulkFileInputRef = useRef(null)
const [uploadVis, setUploadVis] = useState('private')
const [uploadClubId, setUploadClubId] = useState('')
const [uploadBusy, setUploadBusy] = useState(false)
const [uploadSummary, setUploadSummary] = useState('')
// P-06: Rechte-Dialog (Upload)
const [rightsDialogOpen, setRightsDialogOpen] = useState(false)
const [pendingUploadFiles, setPendingUploadFiles] = useState(null)
// P-06: Rechte-Dialog (Sichtbarkeits-Promotion)
const [rightsUpgradeDialogOpen, setRightsUpgradeDialogOpen] = useState(false)
const [rightsUpgradeMode, setRightsUpgradeMode] = useState('promotion')
const [pendingPatchBody, setPendingPatchBody] = useState(null)
const [journalModal, setJournalModal] = useState(null)
const [journalLoading, setJournalLoading] = useState(false)
const [journalCorrectionOpen, setJournalCorrectionOpen] = useState(false)
const [journalCorrectionDraft, setJournalCorrectionDraft] = useState(null)
const [journalCorrectionBusy, setJournalCorrectionBusy] = useState(false)
// P-11: Legal Hold Dialog
const [legalHoldDialog, setLegalHoldDialog] = useState(null) // { mode: 'set'|'release', assetId, assetName }
const [legalHoldReasonCode, setLegalHoldReasonCode] = useState('rights_dispute')
const [legalHoldReasonNote, setLegalHoldReasonNote] = useState('')
const [legalHoldReleaseNote, setLegalHoldReleaseNote] = useState('')
const [legalHoldBusy, setLegalHoldBusy] = useState(false)
const mediaListFetchSeqRef = useRef(0)
const gridTopAnchorRef = useRef(null)
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])
useEffect(() => {
if (!isSuperadmin) return
const cid = user?.effective_club_id ?? user?.active_club_id
if (cid == null || cid === '') return
setUploadClubId(String(cid))
}, [isSuperadmin, user?.effective_club_id, user?.active_club_id])
const loadClubs = useCallback(async () => {
try {
const c = await api.listClubs()
setClubs(Array.isArray(c) ? c : [])
} catch {
setClubs([])
}
}, [])
useEffect(() => {
loadClubs()
}, [loadClubs, tenantClubDepKey])
const loadItems = useCallback(async () => {
const seq = ++mediaListFetchSeqRef.current
setLoading(true)
setError('')
try {
const res = await api.listMediaAssets({
lifecycle,
q: q.trim(),
limit: 100,
offset: 0,
media_kind: mediaKind,
include_filter_meta: true,
...(isSuperadmin && filterClubId
? { club_id: Number(filterClubId) }
: {}),
...(filterUploaderId && viewer?.show_uploader_meta
? { uploaded_by: Number(filterUploaderId) }
: {}),
})
if (seq !== mediaListFetchSeqRef.current) return
setItems(res.items || [])
setViewer(res.viewer || null)
if (res.filter_meta?.uploaders?.length) {
setUploaderFilterOptions(res.filter_meta.uploaders)
}
setSelected(new Set())
} catch (e) {
if (seq !== mediaListFetchSeqRef.current) return
setError(e.message || String(e))
} finally {
if (seq === mediaListFetchSeqRef.current) setLoading(false)
}
}, [lifecycle, q, mediaKind, filterClubId, filterUploaderId, isSuperadmin, viewer?.show_uploader_meta, tenantClubDepKey])
useEffect(() => {
const t = setTimeout(() => {
loadItems()
}, 320)
return () => clearTimeout(t)
}, [lifecycle, q, mediaKind, filterClubId, filterUploaderId, loadItems])
useEffect(() => {
if (!preview) return
const onKey = (e) => {
if (e.key === 'Escape') setPreview(null)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [preview])
const toggleSel = (id) => {
setSelected((prev) => {
const n = new Set(prev)
if (n.has(id)) n.delete(id)
else n.add(id)
return n
})
}
const selectAll = () => {
if (selected.size === items.length) setSelected(new Set())
else setSelected(new Set(items.map((x) => x.id)))
}
const openEdit = (it) => {
setPreview(null)
const p = it.permissions || {}
setModal(it)
setModalDraft({
display_name: it.original_filename != null ? String(it.original_filename) : '',
copyright_notice: it.copyright_notice != null ? String(it.copyright_notice) : '',
tags_input: Array.isArray(it.tags) ? it.tags.join(', ') : '',
visibility: (it.visibility || 'private').toLowerCase(),
club_id: it.club_id != null ? String(it.club_id) : '',
superTarget: 'active',
})
}
const closeModal = () => {
setModal(null)
setModalDraft(null)
}
const openJournal = async (it) => {
setJournalLoading(true)
try {
const data = await api.getMediaAssetJournal(it.id)
setJournalModal(data)
} catch (e) {
alert(e.message || String(e))
} finally {
setJournalLoading(false)
}
}
const openLegalHoldSet = (it) => {
setLegalHoldReasonCode('rights_dispute')
setLegalHoldReasonNote('')
setLegalHoldDialog({ mode: 'set', assetId: it.id, assetName: it.original_filename || `#${it.id}` })
}
const openLegalHoldRelease = (it) => {
setLegalHoldReleaseNote('')
setLegalHoldDialog({ mode: 'release', assetId: it.id, assetName: it.original_filename || `#${it.id}` })
}
const submitLegalHold = async () => {
if (!legalHoldDialog) return
if (legalHoldDialog.mode === 'set') {
if (!legalHoldReasonNote.trim() || legalHoldReasonNote.trim().length < 5) {
alert('Bitte gib eine Begründung ein (mind. 5 Zeichen).')
return
}
} else {
if (!legalHoldReleaseNote.trim() || legalHoldReleaseNote.trim().length < 5) {
alert('Bitte gib eine Freigabe-Begründung ein (mind. 5 Zeichen).')
return
}
}
setLegalHoldBusy(true)
try {
if (legalHoldDialog.mode === 'set') {
await api.setMediaAssetLegalHold(legalHoldDialog.assetId, legalHoldReasonCode, legalHoldReasonNote)
} else {
await api.releaseMediaAssetLegalHold(legalHoldDialog.assetId, legalHoldReleaseNote)
}
setLegalHoldDialog(null)
setModal(null)
setModalDraft(null)
await loadItems()
} catch (e) {
alert(e.message || String(e))
} finally {
setLegalHoldBusy(false)
}
}
function parseApiErrorCode(msg) {
try {
const d = JSON.parse(msg)
if (d && d.code) return d
} catch { /* not JSON */ }
return null
}
const saveModal = async () => {
if (!modal || !modalDraft) return
const p = modal.permissions || {}
const body = {}
if (p.edit_metadata) {
body.original_filename = modalDraft.display_name
body.copyright_notice = modalDraft.copyright_notice
body.tags = parseTagsInput(modalDraft.tags_input)
}
if (p.change_visibility) {
body.visibility = modalDraft.visibility
if (modalDraft.visibility === 'club' || (modalDraft.visibility === 'private' && isPlatformAdmin)) {
const cid = Number(modalDraft.club_id)
if (!cid) {
alert('Bitte einen Verein wählen.')
return
}
body.club_id = cid
}
}
if (!Object.keys(body).length) return
setBusy(true)
try {
await api.patchMediaAsset(modal.id, body)
closeModal()
await loadItems()
} catch (e) {
const parsed = parseApiErrorCode(e.message)
if (parsed && (parsed.code === 'RIGHTS_SCOPE_INSUFFICIENT' || parsed.code === 'LEGACY_REDECLARATION_REQUIRED')) {
setPendingPatchBody(body)
setRightsUpgradeMode(parsed.code === 'LEGACY_REDECLARATION_REQUIRED' ? 'redeclaration' : 'promotion')
setRightsUpgradeDialogOpen(true)
return
}
alert(e.message || String(e))
} finally {
setBusy(false)
}
}
const doSaveWithRightsDecl = async (decl) => {
setRightsUpgradeDialogOpen(false)
if (!modal || !pendingPatchBody) return
const body = { ...pendingPatchBody, ...decl }
setPendingPatchBody(null)
setBusy(true)
try {
await api.patchMediaAsset(modal.id, body)
closeModal()
await loadItems()
} catch (e) {
alert(e.message || String(e))
} finally {
setBusy(false)
}
}
const runLc = async (id, action, confirmMsg, extra = {}) => {
if (confirmMsg && !window.confirm(confirmMsg)) return
setBusy(true)
try {
await api.postMediaAssetLifecycle(id, action, extra)
await loadItems()
closeModal()
} catch (e) {
alert(e.message || String(e))
} finally {
setBusy(false)
}
}
const runBulkPatch = async () => {
const ids = [...selected]
if (!ids.length) return
const body = { media_asset_ids: ids }
let has = false
if (bulkCopyright.trim()) {
body.copyright_notice = bulkCopyright.trim()
has = true
}
if (bulkApplyVis) {
body.visibility = bulkVis
if (bulkVis === 'club' || (bulkVis === 'private' && isPlatformAdmin)) {
const cid = Number(bulkClubId)
if (!cid) {
alert('Bitte einen Verein wählen.')
return
}
body.club_id = cid
}
has = true
}
if (!has) {
alert('Mindestens Copyright ausfüllen oder „Sichtbarkeit ändern“ aktivieren.')
return
}
setBusy(true)
try {
const res = await api.bulkPatchMediaAssets(body)
if (res.failed_count) {
alert(
`${res.updated_count} aktualisiert, ${res.failed_count} fehlgeschlagen. Erste Meldung: ${res.failed[0]?.detail || ''}`,
)
}
setBulkOpen(false)
await loadItems()
} catch (e) {
alert(e.message || String(e))
} finally {
setBusy(false)
}
}
const runBulkLifecycle = async (action) => {
const ids = [...selected]
if (!ids.length) return
setBusy(true)
try {
const res = await api.bulkMediaLifecycle({ media_asset_ids: ids, action })
if (res.failed_count) {
alert(
`${res.updated_count} OK, ${res.failed_count} fehlgeschlagen: ${res.failed[0]?.detail || ''}`,
)
}
await loadItems()
} catch (e) {
alert(e.message || String(e))
} finally {
setBusy(false)
}
}
const selCount = selected.size
const onBulkArchiveFiles = (e) => {
const fl = e.target.files
if (!fl?.length) return
const list = Array.from(fl)
e.target.value = ''
if (uploadVis === 'club' && !Number(uploadClubId)) {
window.alert('Bitte einen Verein für die Sichtbarkeit „Verein” wählen.')
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
}
// P-06: Rechte-Dialog vor Upload anzeigen
setPendingUploadFiles(list)
setRightsDialogOpen(true)
}
const doUploadWithDecl = async (decl) => {
setRightsDialogOpen(false)
const list = pendingUploadFiles
setPendingUploadFiles(null)
if (!list?.length) return
setUploadBusy(true)
setUploadSummary('')
try {
const res = await api.bulkUploadMediaAssets(list, {
visibility: uploadVis,
...((uploadVis === 'club' || (uploadVis === 'private' && isPlatformAdmin)) && Number(uploadClubId)
? { club_id: Number(uploadClubId) }
: {}),
...decl,
})
setUploadSummary(
`Archiv-Upload: neu ${res.created_count}, bereits vorhanden ${res.duplicate_count}, fehlgeschlagen ${res.failed_count}. Liste aktualisiert.`,
)
if (res.created_count > 0 || res.duplicate_count > 0) {
setFilterUploaderId('')
}
await loadItems()
window.requestAnimationFrame(() => {
gridTopAnchorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
} catch (err) {
window.alert(err.message || String(err))
} finally {
setUploadBusy(false)
}
}
return (
<div className="app-page media-library">
<RightsDeclarationDialog
open={rightsDialogOpen}
onCancel={() => { setRightsDialogOpen(false); setPendingUploadFiles(null) }}
onConfirm={doUploadWithDecl}
targetVisibility={uploadVis}
mode="upload"
/>
<RightsDeclarationDialog
open={rightsUpgradeDialogOpen}
onCancel={() => { setRightsUpgradeDialogOpen(false); setPendingPatchBody(null) }}
onConfirm={doSaveWithRightsDecl}
targetVisibility={pendingPatchBody?.visibility || modal?.visibility || 'club'}
isPromotion={true}
mode={rightsUpgradeMode}
/>
<div className="media-library__container">
<header className="media-library__hero">
<div className="media-library__hero-row">
<h1 className="media-library__title">Medienbibliothek</h1>
<div className="media-library__hero-links">
<Link to="/">Übersicht</Link>
<Link to="/exercises">Übungen</Link>
{isSuperadmin ? <Link to="/admin">Globale Administration</Link> : null}
{hasClubOrgAdmin ? <Link to="/clubs">Vereine &amp; Mitglieder</Link> : null}
</div>
</div>
<p className="media-library__intro">
Offizielle und vereinsfreigegebene Medien sind für alle passenden Nutzer sichtbar. Eigene private Medien
kannst du bearbeiten, veröffentlichen oder in den Papierkorb legen; im Papierkorb siehst du als Standardnutzer
nur deine eigenen privaten Objekte, als Vereinsadmin zusätzlich den Vereins-Papierkorb. Vereins-Rollen können
Vereins-Medien verwalten, aber nicht bis „Offiziell“ anheben — das bleibt dem Superadmin vorbehalten.
Plattform-Admins geben beim privaten Upload den Zielverein an (club_id).
</p>
</header>
<div className="media-library__toolbar">
<div className="media-library__toolbar-row">
<input
type="search"
className="form-input media-library__search"
placeholder="Suche Bezeichner, Pfade, Copyright, Tags "
value={q}
onChange={(e) => setQ(e.target.value)}
aria-label="Suche"
/>
<select
className="form-input media-library__select"
value={lifecycle}
onChange={(e) => setLifecycle(e.target.value)}
aria-label="Lebenszyklus"
>
{LC_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<div className="media-library__view-toggle" role="group" aria-label="Darstellung">
<button
type="button"
className={`media-library__icon-btn${viewMode === 'grid' ? ' media-library__icon-btn--on' : ''}`}
onClick={() => setViewMode('grid')}
title="Kacheln"
>
<LayoutGrid size={20} />
</button>
<button
type="button"
className={`media-library__icon-btn${viewMode === 'list' ? ' media-library__icon-btn--on' : ''}`}
onClick={() => setViewMode('list')}
title="Liste"
>
<List size={20} />
</button>
</div>
<button
type="button"
className="btn btn-secondary media-library__refresh"
onClick={loadItems}
disabled={loading}
>
Aktualisieren
</button>
</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__upload-row" aria-label="Archiv hochladen">
<input
ref={bulkFileInputRef}
type="file"
className="media-library__sr-file"
accept="image/*,video/*,application/pdf"
multiple
onChange={onBulkArchiveFiles}
/>
<button
type="button"
className="btn btn-secondary"
disabled={uploadBusy}
onClick={() => bulkFileInputRef.current?.click()}
title="Mehrere Dateien ins Archiv laden"
>
<Upload size={18} aria-hidden className="media-library__upload-icon" />
Archiv-Upload
</button>
<select
className="form-input"
value={uploadVis}
onChange={(e) => {
setUploadVis(e.target.value)
setUploadSummary('')
}}
aria-label="Sichtbarkeit für neuen Upload"
>
{archiveVisOptions.map((o) => (
<option key={o.value} value={o.value}>
Upload: {o.label}
</option>
))}
</select>
{uploadVis === 'club' || (uploadVis === 'private' && isPlatformAdmin) ? (
<select
className="form-input"
value={uploadClubId}
onChange={(e) => setUploadClubId(e.target.value)}
aria-label="Verein für Upload"
>
<option value="">Verein wählen…</option>
{clubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || c.id}
</option>
))}
</select>
) : null}
{uploadSummary ? (
<span className="media-library__upload-summary" role="status">
{uploadSummary}
</span>
) : null}
</div>
<div className="media-library__toolbar-meta">
<label className="media-library__check-all">
<input type="checkbox" checked={items.length > 0 && selCount === items.length} onChange={selectAll} />
<span>alle sichtbaren</span>
</label>
{selCount > 0 ? (
<button type="button" className="btn btn-secondary" onClick={() => setBulkOpen(true)}>
Bulk ({selCount})
</button>
) : null}
</div>
</div>
{error ? (
<p className="media-library__err" role="alert">
{error}
</p>
) : null}
<div ref={gridTopAnchorRef} className="media-library__grid-top-anchor" aria-hidden="true" />
{loading && !items.length ? <div className="spinner media-library__spinner" /> : null}
{!loading && !items.length && !error ? (
<p className="media-library__empty">Keine Medien für diese Filter.</p>
) : null}
{viewMode === 'grid' && items.length > 0 ? (
<div className="media-library__grid">
{items.map((it) => {
const chk = selected.has(it.id)
return (
<div key={it.id} className="media-library__card">
<label className="media-library__card-check">
<input type="checkbox" checked={chk} onChange={() => toggleSel(it.id)} />
</label>
<button
type="button"
className="media-library__card-menu"
onClick={() => openEdit(it)}
title="Bearbeiten"
aria-label="Bearbeiten"
>
<MoreVertical size={20} />
</button>
<button
type="button"
className="media-library__card-thumb-hit"
onClick={() => setPreview(it)}
title="Vorschau"
>
<div className="media-library__card-thumb-wrap">
<MediaTypeGlyph 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>
</button>
<div className="media-library__card-footer">
<div className="media-library__card-name" title={it.original_filename || `#${it.id}`}>
{it.original_filename || `Medium #${it.id}`}
</div>
<div className="media-library__card-footer-row">
<MediaCardScopeStatus visibility={it.visibility} lifecycleState={it.lifecycle_state} />
{it.legal_hold_active && (
<span className="media-library__legal-hold-badge" title="Sofortsperre aktiv (Legal Hold)">
Sofort gesperrt
</span>
)}
{!it.legal_hold_active && it.rights_status === 'legacy_unreviewed' && (
<span
style={{ fontSize: '0.7rem', color: 'var(--danger)', marginLeft: 4 }}
title="Altbestand Rechtserklärung nach neuem Standard (P-06) noch nicht erfasst"
>Altbestand ⚠</span>
)}
{it.open_report_count > 0 && (
<span
style={{
fontSize: '0.7rem',
background: 'var(--danger)',
color: '#fff',
borderRadius: '10px',
padding: '1px 6px',
marginLeft: 4,
fontWeight: 600,
}}
title={`${it.open_report_count} offene Inhaltsmeldung${it.open_report_count > 1 ? 'en' : ''}`}
>{it.open_report_count} Meldung{it.open_report_count > 1 ? 'en' : ''}</span>
)}
</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 returnContext={mediaLibraryReturn} />
{(it.lifecycle_state || 'active') === 'active' && !it.legal_hold_active && (
<button
type="button"
onClick={() => setReportTarget(it)}
style={{
background: 'none',
border: 'none',
padding: '2px 0 0',
cursor: 'pointer',
fontSize: '0.72rem',
color: 'var(--text3)',
textDecoration: 'underline',
textAlign: 'left',
}}
>
Melden
</button>
)}
</div>
</div>
)
})}
</div>
) : null}
{viewMode === 'list' && items.length > 0 ? (
<div className="media-library__table-wrap">
<table className="media-library__table">
<thead>
<tr>
<th className="media-library__th-check" />
<th>Vorschau</th>
<th>Bezeichnung</th>
<th>Kennzeichen</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_uploader_meta ? <th>Uploader</th> : null}
<th className="media-library__th-act" />
</tr>
</thead>
<tbody>
{items.map((it) => {
return (
<tr key={it.id}>
<td>
<input type="checkbox" checked={selected.has(it.id)} onChange={() => toggleSel(it.id)} />
</td>
<td className="media-library__td-thumb">
<button
type="button"
className="media-library__table-thumb-hit"
onClick={() => setPreview(it)}
title="Vorschau"
>
<div className="media-library__table-thumb">
<MediaTypeGlyph mimeType={it.mime_type} compact />
<MediaThumb mediaId={it.id} mimeType={it.mime_type} />
</div>
</button>
</td>
<td className="media-library__td-name">{it.original_filename || `#${it.id}`}</td>
<td className="media-library__td-glyphs">
<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 returnContext={mediaLibraryReturn} />
</td>
{viewer?.show_club_meta ? (
<td className="media-library__td-sub">{it.club_name || it.club_id || '—'}</td>
) : null}
{viewer?.show_uploader_meta ? (
<td className="media-library__td-sub">{uploaderLabel(it, viewer) || '—'}</td>
) : null}
<td style={{ whiteSpace: 'nowrap' }}>
<button
type="button"
className="media-library__icon-btn"
onClick={() => openEdit(it)}
aria-label="Bearbeiten"
>
<MoreVertical size={18} />
</button>
{(it.lifecycle_state || 'active') === 'active' && !it.legal_hold_active && (
<button
type="button"
className="media-library__icon-btn"
onClick={() => setReportTarget(it)}
aria-label="Melden"
title="Inhalt melden"
style={{ color: 'var(--text3)', fontSize: '0.72rem', padding: '4px 6px' }}
>
Melden
</button>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
) : null}
</div>
{preview ? (
<MediaPreviewModal
title={preview.original_filename || `Medium #${preview.id}`}
media={{ ...preview, asset_legal_hold_active: preview.legal_hold_active }}
fileUrl={resolveMediaAssetFileUrl(preview.id)}
onClose={() => setPreview(null)}
onReport={
(preview.lifecycle_state || 'active') === 'active' && !preview.legal_hold_active
? () => { setReportTarget(preview); setPreview(null) }
: null
}
onEdit={() => { openEdit(preview); setPreview(null) }}
/>
) : null}
{bulkOpen ? (
<div className="media-library__overlay" role="dialog" aria-modal="true" aria-labelledby="bulk-title">
<div className="media-library__modal media-library__modal--wide">
<div className="media-library__modal-head">
<h2 id="bulk-title">Bulk für {selCount} Medien</h2>
<button type="button" className="media-library__icon-btn" onClick={() => setBulkOpen(false)} aria-label="Schließen">
<X size={22} />
</button>
</div>
<div className="media-library__modal-body">
<p className="media-library__hint">Nur ausgefüllte Felder werden gesetzt. Fehlende Rechte pro Medium werden im Ergebnis gemeldet.</p>
<label className="form-label">Copyright (optional)</label>
<textarea
className="form-input"
rows={2}
value={bulkCopyright}
onChange={(e) => setBulkCopyright(e.target.value)}
/>
<label className="media-library__check">
<input
type="checkbox"
checked={bulkApplyVis}
onChange={(e) => setBulkApplyVis(e.target.checked)}
/>
<span>Sichtbarkeit ändern</span>
</label>
{bulkApplyVis ? (
<>
<select className="form-input" value={bulkVis} onChange={(e) => setBulkVis(e.target.value)}>
{archiveVisOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{bulkVis === 'club' || (bulkVis === 'private' && isPlatformAdmin) ? (
<select className="form-input" value={bulkClubId} onChange={(e) => setBulkClubId(e.target.value)}>
<option value="">— Verein —</option>
{clubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || c.id}
</option>
))}
</select>
) : null}
</>
) : null}
<div className="media-library__modal-actions">
<button type="button" className="btn btn-secondary" disabled={busy} onClick={runBulkPatch}>
Metadaten anwenden
</button>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() => {
if (!window.confirm('Ausgewählte aktiven Medien in Papierkorb (1)?')) return
runBulkLifecycle('trash_soft')
}}
>
Papierkorb (1)
</button>
{isSuperadmin ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() => {
if (!window.confirm('Superadmin: Ausgewählte endgültig löschen?')) return
runBulkLifecycle('superadmin_hard_delete')
}}
>
Hard-Delete
</button>
) : null}
</div>
</div>
</div>
</div>
) : null}
{modal && modalDraft ? (
<div className="media-library__overlay" role="dialog" aria-modal="true" aria-labelledby="edit-media-title">
<div className="media-library__modal">
<div className="media-library__modal-head">
<h2 id="edit-media-title">
Medium #{modal.id}
{(modal.visibility || '').toLowerCase() === 'official' && !isSuperadmin
? ' · Nur Lesen'
: ''}
</h2>
<button type="button" className="media-library__icon-btn" onClick={closeModal} aria-label="Schließen">
<X size={22} />
</button>
</div>
<div className="media-library__modal-body">
{(viewer?.show_club_meta ||
viewer?.show_uploader_meta ||
(modal.visibility || '').toLowerCase() === 'official') && (
<div className="media-library__meta-block">
{viewer?.show_club_meta ? (
<div>
<span className="media-library__meta-k">Verein</span>
<span className="media-library__meta-v">{modal.club_name || modal.club_id || '—'}</span>
</div>
) : null}
{viewer?.show_uploader_meta ? (
<div>
<span className="media-library__meta-k">Uploader</span>
<span className="media-library__meta-v">{uploaderLabel(modal, viewer) || '—'}</span>
</div>
) : null}
<div>
<span className="media-library__meta-k">Technisch</span>
<span className="media-library__meta-v mono">
{modal.mime_type || '—'} · ID {modal.id}
{isSuperadmin && modal.storage_key ? ` · ${modal.storage_key}` : ''}
</span>
</div>
</div>
)}
{modal.permissions?.edit_metadata ? (
<>
<label className="form-label">Bezeichnung (Dateiname / Anzeige)</label>
<input
className="form-input"
value={modalDraft.display_name}
onChange={(e) => setModalDraft((d) => ({ ...d, display_name: e.target.value }))}
/>
<label className="form-label">Copyright</label>
<textarea
className="form-input"
rows={3}
value={modalDraft.copyright_notice}
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"
/>
</>
) : (modal.visibility || '').toLowerCase() === 'official' ? (
<>
<p className="media-library__hint">
Offizielle Medien sind für alle sichtbar. Bearbeiten, Sichtbarkeit und Löschung nur als Superadmin.
</p>
<label className="form-label">Bezeichnung</label>
<input className="form-input" readOnly value={modalDraft.display_name} />
<label className="form-label">Copyright</label>
<textarea className="form-input" readOnly rows={3} value={modalDraft.copyright_notice} />
<label className="form-label">Schlagwörter</label>
<input className="form-input" readOnly value={modalDraft.tags_input} />
</>
) : (
<p className="media-library__hint">Keine Berechtigung für Metadaten — nur Verwaltende dieser Stufe.</p>
)}
{modal.permissions?.change_visibility ? (
<>
<label className="form-label">Sichtbarkeit</label>
<select
className="form-input"
value={modalDraft.visibility}
onChange={(e) => setModalDraft((d) => ({ ...d, visibility: e.target.value }))}
>
{modalVisibilityOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{modalDraft.visibility === 'club' || (modalDraft.visibility === 'private' && isPlatformAdmin) ? (
<>
<label className="form-label">Verein</label>
<select
className="form-input"
value={modalDraft.club_id}
onChange={(e) => setModalDraft((d) => ({ ...d, club_id: e.target.value }))}
>
<option value="">— wählen —</option>
{clubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || c.id}
</option>
))}
</select>
</>
) : 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} returnContext={mediaLibraryReturn} />
</div>
</div>
<div className="media-library__modal-actions">
{modal.permissions?.edit_metadata ? (
<button type="button" className="btn btn-secondary" disabled={busy} onClick={saveModal}>
Speichern
</button>
) : null}
</div>
<div className="media-library__lc-block">
<div className="media-library__lc-title">Lebenszyklus</div>
<div className="media-library__lc-btns">
{modal.permissions?.trash_soft ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() =>
runLc(modal.id, 'trash_soft', 'In Papierkorb (Stufe 1) legen?')
}
>
Papierkorb
</button>
) : null}
{modal.permissions?.trash_hidden ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() =>
runLc(
modal.id,
'trash_hidden',
'Ausblenden (Stufe 2)? Öffentliche Ansicht verliert das Medium.',
)
}
>
Ausblenden
</button>
) : null}
{modal.permissions?.recover ? (
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => runLc(modal.id, 'recover', null)}>
↩ Stufe 1
</button>
) : null}
{modal.permissions?.reactivate ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() => runLc(modal.id, 'reactivate', null)}
>
Wieder aktiv
</button>
) : null}
{modal.permissions?.purge ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() =>
runLc(
modal.id,
'purge',
'Endgültig löschen (nur Superadmin, Stufe 2)? Datei und DB-Eintrag.',
)
}
>
Endgültig löschen
</button>
) : null}
</div>
</div>
{isSuperadmin ? (
<div className="media-library__lc-block">
<div className="media-library__lc-title">Medienjournal</div>
<button
type="button"
className="btn btn-secondary"
disabled={journalLoading}
onClick={() => openJournal(modal)}
style={{ display: 'flex', alignItems: 'center', gap: 6 }}
>
<ScrollText size={14} aria-hidden />
Einwilligungs-Journal
</button>
</div>
) : null}
{isSuperadmin ? (
<div className="media-library__lc-block media-library__lc-block--legal-hold">
<div className="media-library__lc-title">Sofortsperre (Legal Hold)</div>
{modal.legal_hold_active ? (
<div>
<p className="media-library__legal-hold-info">
<strong>Aktiv seit:</strong>{' '}
{modal.legal_hold_set_at ? new Date(modal.legal_hold_set_at).toLocaleString('de-DE') : '—'}
{modal.legal_hold_reason_code ? (
<> · <strong>Grund:</strong> {LEGAL_HOLD_REASON_LABELS[modal.legal_hold_reason_code] || modal.legal_hold_reason_code}</>
) : null}
</p>
{modal.legal_hold_reason_note ? (
<p className="media-library__legal-hold-info">{modal.legal_hold_reason_note}</p>
) : null}
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() => openLegalHoldRelease(modal)}
>
Sperre aufheben
</button>
</div>
) : (
<button
type="button"
className="btn btn-secondary media-library__btn--legal-hold"
disabled={busy}
onClick={() => openLegalHoldSet(modal)}
>
Sofort sperren
</button>
)}
</div>
) : null}
{modal.permissions?.superadmin_lifecycle ? (
<div className="media-library__lc-block media-library__lc-block--danger">
<div className="media-library__lc-title">Superadmin</div>
<div className="media-library__row">
<select
className="form-input"
value={modalDraft.superTarget}
onChange={(e) => setModalDraft((d) => ({ ...d, superTarget: e.target.value }))}
>
<option value="active">aktiv</option>
<option value="trash_soft">Papierkorb 1</option>
<option value="trash_hidden">ausgeblendet 2</option>
</select>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() =>
runLc(
modal.id,
'superadmin_force_lifecycle',
`Zustand erzwingen: ${modalDraft.superTarget}?`,
{ target_lifecycle: modalDraft.superTarget },
)
}
>
Zustand setzen
</button>
</div>
{modal.permissions?.superadmin_hard_delete ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() =>
runLc(
modal.id,
'superadmin_hard_delete',
'Medium komplett entfernen (DB + Datei)?',
)
}
>
Komplett löschen
</button>
) : null}
</div>
) : null}
</div>
</div>
</div>
) : null}
{journalModal ? (
<div className="media-library__overlay" role="dialog" aria-modal="true" aria-labelledby="journal-modal-title">
<div className="media-library__modal media-library__modal--wide">
<div className="media-library__modal-head">
<h2 id="journal-modal-title">
Journal · Medium #{journalModal.asset.id}
{journalModal.asset.original_filename ? ` · ${journalModal.asset.original_filename}` : ''}
</h2>
<button
type="button"
className="media-library__icon-btn"
onClick={() => { setJournalModal(null); setJournalCorrectionOpen(false) }}
aria-label="Schließen"
>
<X size={22} />
</button>
</div>
<div className="media-library__modal-body">
<div className="media-library__meta-block">
<div>
<span className="media-library__meta-k">Rechtsstatus</span>
<span className="media-library__meta-v">{journalModal.asset.rights_status}</span>
</div>
<div>
<span className="media-library__meta-k">Sichtbarkeit</span>
<span className="media-library__meta-v">{visibilityUiLabel(journalModal.asset.visibility)}</span>
</div>
{journalModal.asset.copyright_notice ? (
<div>
<span className="media-library__meta-k">Copyright</span>
<span className="media-library__meta-v">{journalModal.asset.copyright_notice}</span>
</div>
) : null}
</div>
{/* Chronologische Event-Timeline */}
{(journalModal.events || journalModal.declarations || []).length === 0 ? (
<p className="media-library__hint">Noch keine Einträge für dieses Medium.</p>
) : (
<div className="media-library__journal-list">
{(journalModal.events || journalModal.declarations || []).map((evt, idx) => {
if (evt.kind === 'audit') {
const byLabel = evt.acting_name || evt.acting_email
|| (evt.acting_profile_id ? `Profil #${evt.acting_profile_id}` : '—')
const old = evt.old_values || {}
const nw = evt.new_values || {}
return (
<div key={`a-${idx}`} className="media-library__journal-entry media-library__journal-entry--audit">
<div className="media-library__journal-entry-head">
<span className="media-library__journal-action media-library__journal-action--audit">
{eventTypeLabel(evt.event_type)}
</span>
<span className="media-library__journal-date">
{new Date(evt.occurred_at).toLocaleString('de-DE')}
</span>
</div>
<div className="media-library__journal-entry-by">{byLabel}</div>
<div className="media-library__journal-audit-vals">
{evt.event_type === 'visibility_change' ? (
<span>{visLabel(old.visibility)} {visLabel(nw.visibility)}</span>
) : evt.event_type === 'lifecycle_change' ? (
<span>{lcLabel(old.lifecycle_state)} {lcLabel(nw.lifecycle_state)}</span>
) : evt.event_type === 'copyright_change' ? (
<>
<div className="media-library__journal-audit-row">
<span className="media-library__journal-audit-label">Alt:</span>
<span>{old.copyright_notice || '—'}</span>
</div>
<div className="media-library__journal-audit-row">
<span className="media-library__journal-audit-label">Neu:</span>
<span>{nw.copyright_notice || '—'}</span>
</div>
</>
) : evt.event_type === 'legal_hold_set' ? (
<>
<div className="media-library__journal-audit-row">
<span className="media-library__journal-audit-label">Grund:</span>
<span>{LEGAL_HOLD_REASON_LABELS[nw.reason_code] || nw.reason_code || '—'}</span>
</div>
{nw.reason_note ? (
<div className="media-library__journal-audit-row">
<span className="media-library__journal-audit-label">Begründung:</span>
<span>{nw.reason_note}</span>
</div>
) : null}
</>
) : evt.event_type === 'legal_hold_released' ? (
<>
{nw.release_note ? (
<div className="media-library__journal-audit-row">
<span className="media-library__journal-audit-label">Freigabegrund:</span>
<span>{nw.release_note}</span>
</div>
) : null}
<div className="media-library__journal-audit-row">
<span className="media-library__journal-audit-label">Rechtsstatus wiederhergestellt:</span>
<span>{nw.rights_status || '—'}</span>
</div>
</>
) : evt.event_type === 'content_report_filed' ? (
<>
{nw.content_report_id ? (
<div className="media-library__journal-audit-row">
<span className="media-library__journal-audit-label">Meldungs-ID:</span>
<span>#{nw.content_report_id}</span>
</div>
) : null}
{nw.report_reason ? (
<div className="media-library__journal-audit-row">
<span className="media-library__journal-audit-label">Meldegrund:</span>
<span>{nw.report_reason}</span>
</div>
) : null}
{nw.priority ? (
<div className="media-library__journal-audit-row">
<span className="media-library__journal-audit-label">Priorität:</span>
<span>{nw.priority}</span>
</div>
) : null}
{nw.status ? (
<div className="media-library__journal-audit-row">
<span className="media-library__journal-audit-label">Status:</span>
<span>{old.status ? `${old.status}` : ''}{nw.status}</span>
</div>
) : null}
{nw.begründung ? (
<div className="media-library__journal-audit-row">
<span className="media-library__journal-audit-label">Begründung:</span>
<span>{nw.begründung}</span>
</div>
) : null}
</>
) : (
Object.keys(nw).map((k) => (
<div key={k} className="media-library__journal-audit-row">
<span className="media-library__journal-audit-label">{k}:</span>
<span>{String(old[k] ?? '—')} {String(nw[k] ?? '—')}</span>
</div>
))
)}
</div>
</div>
)
}
// kind === 'declaration' (or legacy array format)
const d = evt
const byLabel = d.declared_by_name || d.declared_by_email
|| (d.declared_by_profile_id ? `Profil #${d.declared_by_profile_id}` : '—')
return (
<div key={`d-${idx}`} className={`media-library__journal-entry${d.action_type === 'correction' ? ' media-library__journal-entry--correction' : ''}`}>
<div className="media-library__journal-entry-head">
<span className={`media-library__journal-action${d.action_type === 'correction' ? ' media-library__journal-action--correction' : ''}`}>
{actionTypeLabel(d.action_type)}
</span>
<span className="media-library__journal-arrow"></span>
<span className="media-library__journal-vis">{visibilityUiLabel(d.target_visibility)}</span>
<span className="media-library__journal-date">
{new Date(d.declared_at).toLocaleString('de-DE')}
</span>
</div>
<div className="media-library__journal-entry-by">
{byLabel}
<span className="media-library__journal-version"> · {d.declaration_version}</span>
</div>
{d.correction_note ? (
<div className="media-library__journal-correction-note">
<strong>Korrekturgrund:</strong> {d.correction_note}
</div>
) : null}
<div className="media-library__journal-fields">
<JournalBoolField label="Rechteinhaber bestätigt" val={d.rights_holder_confirmed} />
<JournalBoolField label="Erkennbare Personen" val={d.contains_identifiable_persons} />
{d.contains_identifiable_persons ? (
<JournalBoolField
label="Einwilligung Personen"
val={d.person_consent_confirmed}
context={d.person_consent_context}
/>
) : null}
<JournalBoolField label="Minderjährige" val={d.contains_minors} />
{d.contains_minors ? (
<JournalBoolField
label="Erziehungsberechtigte Einwilligung"
val={d.parental_consent_confirmed}
context={d.parental_consent_context}
/>
) : null}
<JournalBoolField label="Musik" val={d.contains_music} />
{d.contains_music ? (
<JournalBoolField
label="Musikrechte"
val={d.music_rights_confirmed}
context={d.music_rights_context}
/>
) : null}
<JournalBoolField label="Drittinhalte" val={d.contains_third_party_content} />
{d.contains_third_party_content ? (
<JournalBoolField
label="Drittrechte bestätigt"
val={d.third_party_rights_confirmed}
context={d.third_party_rights_context}
/>
) : null}
</div>
</div>
)
})}
</div>
)}
{/* Korrektur-Formular */}
{journalModal.can_correct && !journalCorrectionOpen ? (
<div style={{ marginTop: 16 }}>
<button
className="btn btn-secondary"
onClick={() => {
setJournalCorrectionDraft({
target_visibility: journalModal.asset.visibility || 'private',
rights_holder_confirmed: false,
contains_identifiable_persons: false,
person_consent_confirmed: null,
contains_minors: false,
parental_consent_confirmed: null,
contains_music: false,
music_rights_confirmed: null,
contains_third_party_content: false,
third_party_rights_confirmed: null,
correction_note: '',
})
setJournalCorrectionOpen(true)
}}
>
Korrektur erfassen
</button>
</div>
) : null}
{journalCorrectionOpen && journalCorrectionDraft ? (
<div className="media-library__journal-correction-form">
<h3 className="media-library__journal-correction-title">Korrektur der Einwilligungserklärung</h3>
<p className="media-library__hint" style={{ marginBottom: 12 }}>
Die Korrektur wird als neuer Eintrag im Journal gespeichert (append-only). Die ursprünglichen Einträge bleiben erhalten.
</p>
<div className="form-row">
<label className="form-label">Ziel-Sichtbarkeit</label>
<select
className="form-input"
value={journalCorrectionDraft.target_visibility}
onChange={(e) => setJournalCorrectionDraft((d) => ({ ...d, target_visibility: e.target.value }))}
>
<option value="private">Privat</option>
<option value="club">Verein</option>
<option value="official">Offiziell</option>
</select>
</div>
<div className="form-row">
<label className="form-label">
<input
type="checkbox"
checked={!!journalCorrectionDraft.rights_holder_confirmed}
onChange={(e) => setJournalCorrectionDraft((d) => ({ ...d, rights_holder_confirmed: e.target.checked }))}
style={{ marginRight: 8 }}
/>
Ich bestätige, dass ich die erforderlichen Rechte an diesem Medium besitze *
</label>
</div>
<div className="form-row">
<label className="form-label">
<input
type="checkbox"
checked={!!journalCorrectionDraft.contains_identifiable_persons}
onChange={(e) => setJournalCorrectionDraft((d) => ({ ...d, contains_identifiable_persons: e.target.checked, person_consent_confirmed: e.target.checked ? d.person_consent_confirmed : null }))}
style={{ marginRight: 8 }}
/>
Erkennbare Personen abgebildet *
</label>
</div>
{journalCorrectionDraft.contains_identifiable_persons ? (
<div className="form-row" style={{ paddingLeft: 24 }}>
<label className="form-label">
<input
type="checkbox"
checked={!!journalCorrectionDraft.person_consent_confirmed}
onChange={(e) => setJournalCorrectionDraft((d) => ({ ...d, person_consent_confirmed: e.target.checked }))}
style={{ marginRight: 8 }}
/>
Einwilligung aller erkennbaren Personen liegt vor *
</label>
</div>
) : null}
<div className="form-row">
<label className="form-label">
<input
type="checkbox"
checked={!!journalCorrectionDraft.contains_minors}
onChange={(e) => setJournalCorrectionDraft((d) => ({ ...d, contains_minors: e.target.checked, parental_consent_confirmed: e.target.checked ? d.parental_consent_confirmed : null }))}
style={{ marginRight: 8 }}
/>
Minderjährige abgebildet *
</label>
</div>
{journalCorrectionDraft.contains_minors ? (
<div className="form-row" style={{ paddingLeft: 24 }}>
<label className="form-label">
<input
type="checkbox"
checked={!!journalCorrectionDraft.parental_consent_confirmed}
onChange={(e) => setJournalCorrectionDraft((d) => ({ ...d, parental_consent_confirmed: e.target.checked }))}
style={{ marginRight: 8 }}
/>
Einwilligung der Erziehungsberechtigten liegt vor *
</label>
</div>
) : null}
<div className="form-row">
<label className="form-label">
<input
type="checkbox"
checked={!!journalCorrectionDraft.contains_music}
onChange={(e) => setJournalCorrectionDraft((d) => ({ ...d, contains_music: e.target.checked, music_rights_confirmed: e.target.checked ? d.music_rights_confirmed : null }))}
style={{ marginRight: 8 }}
/>
Musik enthalten *
</label>
</div>
{journalCorrectionDraft.contains_music ? (
<div className="form-row" style={{ paddingLeft: 24 }}>
<label className="form-label">
<input
type="checkbox"
checked={!!journalCorrectionDraft.music_rights_confirmed}
onChange={(e) => setJournalCorrectionDraft((d) => ({ ...d, music_rights_confirmed: e.target.checked }))}
style={{ marginRight: 8 }}
/>
Musikrechte liegen vor *
</label>
</div>
) : null}
<div className="form-row">
<label className="form-label">
<input
type="checkbox"
checked={!!journalCorrectionDraft.contains_third_party_content}
onChange={(e) => setJournalCorrectionDraft((d) => ({ ...d, contains_third_party_content: e.target.checked, third_party_rights_confirmed: e.target.checked ? d.third_party_rights_confirmed : null }))}
style={{ marginRight: 8 }}
/>
Fremdinhalte (Logos, Grafiken etc.) enthalten *
</label>
</div>
{journalCorrectionDraft.contains_third_party_content ? (
<div className="form-row" style={{ paddingLeft: 24 }}>
<label className="form-label">
<input
type="checkbox"
checked={!!journalCorrectionDraft.third_party_rights_confirmed}
onChange={(e) => setJournalCorrectionDraft((d) => ({ ...d, third_party_rights_confirmed: e.target.checked }))}
style={{ marginRight: 8 }}
/>
Rechte an Fremdinhalten liegen vor *
</label>
</div>
) : null}
<div className="form-row">
<label className="form-label">Korrekturgrund (empfohlen)</label>
<textarea
className="form-input"
rows={2}
value={journalCorrectionDraft.correction_note || ''}
onChange={(e) => setJournalCorrectionDraft((d) => ({ ...d, correction_note: e.target.value }))}
placeholder="Warum wird die Erklärung korrigiert?"
/>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button
className="btn btn-primary"
disabled={journalCorrectionBusy}
onClick={async () => {
setJournalCorrectionBusy(true)
try {
await api.addMediaAssetDeclarationCorrection(journalModal.asset.id, journalCorrectionDraft)
setJournalCorrectionOpen(false)
const fresh = await api.getMediaAssetJournal(journalModal.asset.id)
setJournalModal(fresh)
} catch (e) {
alert(e.message || String(e))
} finally {
setJournalCorrectionBusy(false)
}
}}
>
{journalCorrectionBusy ? 'Speichern…' : 'Korrektur speichern'}
</button>
<button
className="btn btn-secondary"
disabled={journalCorrectionBusy}
onClick={() => setJournalCorrectionOpen(false)}
>
Abbrechen
</button>
</div>
</div>
) : null}
</div>
</div>
</div>
) : null}
{legalHoldDialog ? (
<div className="media-library__overlay" role="dialog" aria-modal="true" aria-labelledby="legal-hold-dialog-title">
<div className="media-library__modal media-library__modal--narrow">
<div className="media-library__modal-head">
<h2 id="legal-hold-dialog-title">
{legalHoldDialog.mode === 'set' ? 'Sofortsperre setzen' : 'Sofortsperre aufheben'}
</h2>
<button
type="button"
className="media-library__icon-btn"
onClick={() => setLegalHoldDialog(null)}
aria-label="Schließen"
disabled={legalHoldBusy}
>
<X size={22} />
</button>
</div>
<div className="media-library__modal-body">
<p className="media-library__legal-hold-dialog-asset">
Medium: <strong>{legalHoldDialog.assetName}</strong>
</p>
{legalHoldDialog.mode === 'set' ? (
<>
<p className="media-library__hint media-library__legal-hold-warning">
Die Sofortsperre sperrt das Medium sofort für alle normalen Nutzer. Der Rechtsstatus wird auf blocked" gesetzt. Die Sperre kann nur von Superadmins aufgehoben werden.
</p>
<div className="form-row">
<label className="form-label">Grund *</label>
<select
className="form-input"
value={legalHoldReasonCode}
onChange={(e) => setLegalHoldReasonCode(e.target.value)}
disabled={legalHoldBusy}
>
{LEGAL_HOLD_REASON_CODES.map((code) => (
<option key={code} value={code}>{LEGAL_HOLD_REASON_LABELS[code]}</option>
))}
</select>
</div>
<div className="form-row">
<label className="form-label">Begründung * (mind. 5 Zeichen)</label>
<textarea
className="form-input"
rows={3}
value={legalHoldReasonNote}
onChange={(e) => setLegalHoldReasonNote(e.target.value)}
placeholder="Konkrete Begründung für die Sofortsperre..."
disabled={legalHoldBusy}
/>
</div>
</>
) : (
<>
<p className="media-library__hint">
Die Aufhebung der Sofortsperre gibt das Medium wieder frei. Der Rechtsstatus wird wiederhergestellt.
</p>
<div className="form-row">
<label className="form-label">Freigabe-Begründung * (mind. 5 Zeichen)</label>
<textarea
className="form-input"
rows={3}
value={legalHoldReleaseNote}
onChange={(e) => setLegalHoldReleaseNote(e.target.value)}
placeholder="Grund für die Aufhebung der Sofortsperre..."
disabled={legalHoldBusy}
/>
</div>
</>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
<button
type="button"
className={`btn ${legalHoldDialog.mode === 'set' ? 'media-library__btn--legal-hold-confirm' : 'btn-primary'}`}
disabled={legalHoldBusy}
onClick={submitLegalHold}
>
{legalHoldBusy
? (legalHoldDialog.mode === 'set' ? 'Sperren…' : 'Aufheben…')
: (legalHoldDialog.mode === 'set' ? 'Jetzt sperren' : 'Sperre aufheben')}
</button>
<button
type="button"
className="btn btn-secondary"
disabled={legalHoldBusy}
onClick={() => setLegalHoldDialog(null)}
>
Abbrechen
</button>
</div>
</div>
</div>
</div>
) : null}
{reportTarget && (
<ReportContentModal
targetType="media_asset"
targetId={reportTarget.id}
targetLabel={reportTarget.original_filename || `Medium #${reportTarget.id}`}
onClose={() => setReportTarget(null)}
onSuccess={() => {
const id = reportTarget.id
setItems((prev) => prev.map((it) =>
it.id === id ? { ...it, open_report_count: (it.open_report_count || 0) + 1 } : it
))
}}
/>
)}
</div>
)
}