shinkan-jinkendo/frontend/src/pages/MediaLibraryPage.jsx
Lars 0edc86e05a
All checks were successful
Deploy Development / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 10s
Test Suite / playwright-tests (push) Successful in 57s
refactor(MediaLibraryPage): remove unused lcLabel function for lifecycle state
2026-05-11 11:37:46 +02:00

1806 lines
72 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 {
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'
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>Trainings­einheiten</strong>{' '}
{tus.map((t) => {
const label =
[t.planned_date, (t.group_name || '').trim()].filter(Boolean).join(' · ') || `Einheit #${t.id}`
const short = label.length > (compact ? 20 : 36) ? `${label.slice(0, compact ? 20 : 36)}` : label
return (
<Link key={t.id} to={`/planning?unit=${t.id}`} title={label}>
{short}
</Link>
)
})}
</div>
) : null}
</div>
)
}
function uploaderLabel(it, viewer) {
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',
}
return MAP[et] || et
}
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 [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)
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)
}
}
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.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>
)}
</div>
{(it.tags || []).length ? (
<div className="media-library__tag-chips">
{(it.tags || []).slice(0, 6).map((tg) => (
<span key={tg} className="media-library__tag-chip">
{tg}
</span>
))}
</div>
) : null}
<MediaUsageBlock usage={it.usage} compact />
</div>
</div>
)
})}
</div>
) : 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>
<button
type="button"
className="media-library__icon-btn"
onClick={() => openEdit(it)}
aria-label="Bearbeiten"
>
<MoreVertical size={18} />
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
) : null}
</div>
{preview ? (
<div
className="media-library__overlay media-library__overlay--preview"
role="dialog"
aria-modal="true"
aria-labelledby="preview-media-title"
onClick={() => setPreview(null)}
>
<div
className="media-library__modal media-library__modal--preview"
onClick={(e) => e.stopPropagation()}
>
<div className="media-library__modal-head">
<h2 id="preview-media-title" className="media-library__preview-title">
{preview.original_filename || `Medium #${preview.id}`}
</h2>
<div className="media-library__preview-head-actions">
<button
type="button"
className="btn btn-secondary btn-small"
onClick={() => {
openEdit(preview)
}}
>
Bearbeiten
</button>
<button
type="button"
className="media-library__icon-btn"
onClick={() => setPreview(null)}
aria-label="Vorschau schließen"
>
<X size={22} />
</button>
</div>
</div>
<div className="media-library__preview-body">
{(() => {
const url = resolveMediaAssetFileUrl(preview.id)
const kind = previewDisplayKind(preview.mime_type)
const mlow = (preview.mime_type || '').toLowerCase()
if (!url) {
return <p className="media-library__hint">Keine Datei-URL.</p>
}
if (mlow.includes('heic') || mlow.includes('heif')) {
return (
<div className="media-library__preview-fallback">
<p className="media-library__hint">
HEIC/HEIF — in diesem Browser oft keine eingebettete Vorschau. Zum Ansehen herunterladen oder im neuen Tab öffnen.
</p>
<a className="btn btn-secondary" href={url} target="_blank" rel="noopener noreferrer">
Datei öffnen
</a>
</div>
)
}
if (kind === 'image') {
return (
<img
key={preview.id}
className="media-library__preview-img"
src={url}
alt=""
/>
)
}
if (kind === 'video') {
return (
<video
key={preview.id}
className="media-library__preview-video"
src={url}
controls
playsInline
preload="metadata"
>
Wiedergabe nicht unterstützt.
</video>
)
}
if (kind === 'pdf') {
return (
<div className="media-library__preview-fallback">
<p className="media-library__hint">PDF — zur Ansicht im neuen Tab öffnen.</p>
<a className="btn btn-secondary" href={url} target="_blank" rel="noopener noreferrer">
PDF öffnen
</a>
</div>
)
}
return (
<div className="media-library__preview-fallback">
<p className="media-library__hint">
Vorschau für diesen Typ nicht verfügbar ({preview.mime_type || 'unbekannt'}).
</p>
<a className="btn btn-secondary" href={url} target="_blank" rel="noopener noreferrer">
Datei öffnen
</a>
</div>
)
})()}
</div>
</div>
</div>
) : 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}
{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>
</>
) : (
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}
</div>
)
}