Some checks failed
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Failing after 41s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Successful in 56s
- InboxPage: Workflow-Balken (Eingegangen > In Bearbeitung > Abgeschlossen) - InboxPage: Meldungen können nach Abschluss wieder geöffnet werden (PATCH status=submitted) - InboxPage: Bearbeitungskommentar separat speicherbar; Reviewer + Datum sichtbar - InboxPage: Fehler beim Laden von Meldungen wird angezeigt statt leerem Bereich - OrgInboxContext: contentReportsError State exposed - ReportContentModal: onSuccess Callback -> Badge in Medienbibliothek sofort aktuell - content_reports PATCH: Reviewer-Felder werden beim Wieder-öffnen zurückgesetzt - content_reports PATCH: Kommentar-Änderungen ohne Statuswechsel werden im Audit-Log protokolliert version: 0.8.92 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2037 lines
84 KiB
JavaScript
2037 lines
84 KiB
JavaScript
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
|
||
import { Link } from 'react-router-dom'
|
||
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 } 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 }) {
|
||
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>Trainingseinheiten</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) {
|
||
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 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])
|
||
|
||
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])
|
||
|
||
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 & 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 />
|
||
{(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 />
|
||
</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} />
|
||
</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>
|
||
)
|
||
}
|