2026-05-08 09:07:51 +02:00
|
|
|
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
2026-05-07 14:10:26 +02:00
|
|
|
|
import { Link } from 'react-router-dom'
|
2026-05-07 14:33:02 +02:00
|
|
|
|
import {
|
|
|
|
|
|
LayoutGrid,
|
|
|
|
|
|
List,
|
|
|
|
|
|
MoreVertical,
|
|
|
|
|
|
X,
|
2026-05-07 16:15:07 +02:00
|
|
|
|
Globe,
|
|
|
|
|
|
Users,
|
|
|
|
|
|
Lock,
|
|
|
|
|
|
CheckCircle2,
|
|
|
|
|
|
Archive,
|
|
|
|
|
|
CircleDot,
|
|
|
|
|
|
FilePenLine,
|
|
|
|
|
|
Copyright,
|
2026-05-08 08:59:13 +02:00
|
|
|
|
Image as LucideImage,
|
|
|
|
|
|
Video as LucideVideo,
|
2026-05-07 21:36:35 +02:00
|
|
|
|
FileText,
|
|
|
|
|
|
File,
|
|
|
|
|
|
Upload,
|
2026-05-07 14:33:02 +02:00
|
|
|
|
} from 'lucide-react'
|
2026-05-07 14:10:26 +02:00
|
|
|
|
import { useAuth } from '../context/AuthContext'
|
|
|
|
|
|
import api from '../utils/api'
|
|
|
|
|
|
import AdminPageNav from '../components/AdminPageNav'
|
|
|
|
|
|
import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
|
|
|
|
|
|
|
|
|
|
|
|
const LC_OPTIONS = [
|
|
|
|
|
|
{ value: 'active', label: 'Aktiv' },
|
2026-05-07 14:33:02 +02:00
|
|
|
|
{ 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' },
|
2026-05-07 14:10:26 +02:00
|
|
|
|
]
|
|
|
|
|
|
|
2026-05-07 16:15:07 +02:00
|
|
|
|
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' },
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-05-08 08:35:15 +02:00
|
|
|
|
/** Kein image/*/video/* — WebKit (iOS Safari) ist damit oft kaputt; alles explizit. */
|
|
|
|
|
|
const MEDIA_UPLOAD_ACCEPT =
|
|
|
|
|
|
'image/jpeg,image/png,image/gif,image/heic,image/heif,video/mp4,video/quicktime,application/pdf,.jpg,.jpeg,.png,.gif,.heic,.heif,.mp4,.mov,.pdf'
|
|
|
|
|
|
|
2026-05-07 16:15:07 +02:00
|
|
|
|
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) {
|
2026-05-07 14:10:26 +02:00
|
|
|
|
const o = LC_OPTIONS.find((x) => x.value === code)
|
|
|
|
|
|
return o ? o.label : code
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 16:15:07 +02:00
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 14:33:02 +02:00
|
|
|
|
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()
|
2026-05-07 21:55:22 +02:00
|
|
|
|
/* iPhone-Fotos: Browser-Vorschau oft nicht nutzbar */
|
|
|
|
|
|
if (mime.includes('heic') || mime.includes('heif')) {
|
|
|
|
|
|
return <div className="media-library__thumb-ph">HEIC</div>
|
|
|
|
|
|
}
|
2026-05-07 14:33:02 +02:00
|
|
|
|
if (mime.startsWith('video/')) {
|
2026-05-08 09:07:51 +02:00
|
|
|
|
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 */
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)
|
2026-05-07 14:33:02 +02:00
|
|
|
|
}
|
|
|
|
|
|
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>
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 14:39:31 +02:00
|
|
|
|
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'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 21:36:35 +02:00
|
|
|
|
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
|
2026-05-08 08:59:13 +02:00
|
|
|
|
if (kind === 'image') Icon = LucideImage
|
|
|
|
|
|
else if (kind === 'video') Icon = LucideVideo
|
2026-05-07 21:36:35 +02:00
|
|
|
|
else if (kind === 'pdf') Icon = FileText
|
|
|
|
|
|
const sz = compact ? 12 : 14
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span
|
2026-05-07 21:42:10 +02:00
|
|
|
|
className={`media-library__card-type${compact ? ' media-library__card-type--compact' : ' media-library__card-type--thumb-bl'}`}
|
2026-05-07 21:36:35 +02:00
|
|
|
|
title={label}
|
|
|
|
|
|
aria-label={`Medientyp: ${label}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon size={sz} strokeWidth={2} aria-hidden />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 14:10:26 +02:00
|
|
|
|
export default function MediaLibraryPage() {
|
|
|
|
|
|
const { user } = useAuth()
|
|
|
|
|
|
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
2026-05-07 14:33:02 +02:00
|
|
|
|
const isSuperadmin = user?.role === 'superadmin'
|
|
|
|
|
|
|
2026-05-07 14:10:26 +02:00
|
|
|
|
const [lifecycle, setLifecycle] = useState('active')
|
|
|
|
|
|
const [q, setQ] = useState('')
|
|
|
|
|
|
const [items, setItems] = useState([])
|
2026-05-07 14:33:02 +02:00
|
|
|
|
const [viewer, setViewer] = useState(null)
|
|
|
|
|
|
const [clubs, setClubs] = useState([])
|
|
|
|
|
|
const [viewMode, setViewMode] = useState('grid')
|
2026-05-07 14:10:26 +02:00
|
|
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
|
|
const [error, setError] = useState('')
|
2026-05-07 14:33:02 +02:00
|
|
|
|
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)
|
2026-05-07 14:39:31 +02:00
|
|
|
|
const [preview, setPreview] = useState(null)
|
2026-05-07 16:15:07 +02:00
|
|
|
|
const [mediaKind, setMediaKind] = useState('all')
|
|
|
|
|
|
const [filterClubId, setFilterClubId] = useState('')
|
|
|
|
|
|
const [filterUploaderId, setFilterUploaderId] = useState('')
|
|
|
|
|
|
const [uploaderFilterOptions, setUploaderFilterOptions] = useState([])
|
2026-05-07 21:36:35 +02:00
|
|
|
|
const bulkFileInputRef = useRef(null)
|
|
|
|
|
|
const [uploadVis, setUploadVis] = useState('private')
|
|
|
|
|
|
const [uploadClubId, setUploadClubId] = useState('')
|
|
|
|
|
|
const [uploadBusy, setUploadBusy] = useState(false)
|
|
|
|
|
|
const [uploadSummary, setUploadSummary] = useState('')
|
2026-05-07 22:02:18 +02:00
|
|
|
|
const mediaListFetchSeqRef = useRef(0)
|
|
|
|
|
|
const gridTopAnchorRef = useRef(null)
|
2026-05-07 14:10:26 +02:00
|
|
|
|
|
2026-05-07 14:33:02 +02:00
|
|
|
|
const loadClubs = useCallback(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const c = await api.listClubs()
|
|
|
|
|
|
setClubs(Array.isArray(c) ? c : [])
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setClubs([])
|
2026-05-07 14:10:26 +02:00
|
|
|
|
}
|
2026-05-07 14:33:02 +02:00
|
|
|
|
}, [])
|
2026-05-07 14:10:26 +02:00
|
|
|
|
|
2026-05-07 14:33:02 +02:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadClubs()
|
|
|
|
|
|
}, [loadClubs])
|
|
|
|
|
|
|
|
|
|
|
|
const loadItems = useCallback(async () => {
|
2026-05-07 22:02:18 +02:00
|
|
|
|
const seq = ++mediaListFetchSeqRef.current
|
2026-05-07 14:10:26 +02:00
|
|
|
|
setLoading(true)
|
|
|
|
|
|
setError('')
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await api.listMediaAssets({
|
|
|
|
|
|
lifecycle,
|
|
|
|
|
|
q: q.trim(),
|
|
|
|
|
|
limit: 100,
|
|
|
|
|
|
offset: 0,
|
2026-05-07 16:15:07 +02:00
|
|
|
|
media_kind: mediaKind,
|
|
|
|
|
|
include_filter_meta: true,
|
|
|
|
|
|
...(isSuperadmin && filterClubId
|
|
|
|
|
|
? { club_id: Number(filterClubId) }
|
|
|
|
|
|
: {}),
|
|
|
|
|
|
...(filterUploaderId && viewer?.show_uploader_meta
|
|
|
|
|
|
? { uploaded_by: Number(filterUploaderId) }
|
|
|
|
|
|
: {}),
|
2026-05-07 14:10:26 +02:00
|
|
|
|
})
|
2026-05-07 22:02:18 +02:00
|
|
|
|
if (seq !== mediaListFetchSeqRef.current) return
|
2026-05-07 14:10:26 +02:00
|
|
|
|
setItems(res.items || [])
|
2026-05-07 14:33:02 +02:00
|
|
|
|
setViewer(res.viewer || null)
|
2026-05-07 16:15:07 +02:00
|
|
|
|
if (res.filter_meta?.uploaders?.length) {
|
|
|
|
|
|
setUploaderFilterOptions(res.filter_meta.uploaders)
|
|
|
|
|
|
}
|
2026-05-07 14:33:02 +02:00
|
|
|
|
setSelected(new Set())
|
2026-05-07 14:10:26 +02:00
|
|
|
|
} catch (e) {
|
2026-05-07 22:02:18 +02:00
|
|
|
|
if (seq !== mediaListFetchSeqRef.current) return
|
2026-05-07 14:10:26 +02:00
|
|
|
|
setError(e.message || String(e))
|
|
|
|
|
|
} finally {
|
2026-05-07 22:02:18 +02:00
|
|
|
|
if (seq === mediaListFetchSeqRef.current) setLoading(false)
|
2026-05-07 14:10:26 +02:00
|
|
|
|
}
|
2026-05-07 16:15:07 +02:00
|
|
|
|
}, [lifecycle, q, mediaKind, filterClubId, filterUploaderId, isSuperadmin, viewer?.show_uploader_meta])
|
2026-05-07 14:33:02 +02:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const t = setTimeout(() => {
|
|
|
|
|
|
loadItems()
|
|
|
|
|
|
}, 320)
|
|
|
|
|
|
return () => clearTimeout(t)
|
2026-05-07 16:15:07 +02:00
|
|
|
|
}, [lifecycle, q, mediaKind, filterClubId, filterUploaderId, loadItems])
|
2026-05-07 14:33:02 +02:00
|
|
|
|
|
2026-05-07 14:39:31 +02:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!preview) return
|
|
|
|
|
|
const onKey = (e) => {
|
|
|
|
|
|
if (e.key === 'Escape') setPreview(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
window.addEventListener('keydown', onKey)
|
|
|
|
|
|
return () => window.removeEventListener('keydown', onKey)
|
|
|
|
|
|
}, [preview])
|
|
|
|
|
|
|
2026-05-07 14:33:02 +02:00
|
|
|
|
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)))
|
2026-05-07 14:10:26 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 14:33:02 +02:00
|
|
|
|
const openEdit = (it) => {
|
2026-05-07 14:39:31 +02:00
|
|
|
|
setPreview(null)
|
2026-05-07 14:33:02 +02:00
|
|
|
|
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) : '',
|
2026-05-07 16:15:07 +02:00
|
|
|
|
tags_input: Array.isArray(it.tags) ? it.tags.join(', ') : '',
|
2026-05-07 14:33:02 +02:00
|
|
|
|
visibility: (it.visibility || 'private').toLowerCase(),
|
|
|
|
|
|
club_id: it.club_id != null ? String(it.club_id) : '',
|
|
|
|
|
|
superTarget: 'active',
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const closeModal = () => {
|
|
|
|
|
|
setModal(null)
|
|
|
|
|
|
setModalDraft(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const saveModal = async () => {
|
|
|
|
|
|
if (!modal || !modalDraft) return
|
|
|
|
|
|
const p = modal.permissions || {}
|
|
|
|
|
|
setBusy(true)
|
2026-05-07 14:10:26 +02:00
|
|
|
|
try {
|
2026-05-07 14:33:02 +02:00
|
|
|
|
const body = {}
|
|
|
|
|
|
if (p.edit_metadata) {
|
|
|
|
|
|
body.original_filename = modalDraft.display_name
|
|
|
|
|
|
body.copyright_notice = modalDraft.copyright_notice
|
2026-05-07 16:15:07 +02:00
|
|
|
|
body.tags = parseTagsInput(modalDraft.tags_input)
|
2026-05-07 14:33:02 +02:00
|
|
|
|
}
|
|
|
|
|
|
if (p.change_visibility) {
|
|
|
|
|
|
body.visibility = modalDraft.visibility
|
|
|
|
|
|
if (modalDraft.visibility === 'club') {
|
|
|
|
|
|
const cid = Number(modalDraft.club_id)
|
|
|
|
|
|
if (!cid) {
|
|
|
|
|
|
alert('Bitte einen Verein wählen.')
|
|
|
|
|
|
setBusy(false)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
body.club_id = cid
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (Object.keys(body).length) {
|
|
|
|
|
|
await api.patchMediaAsset(modal.id, body)
|
|
|
|
|
|
}
|
|
|
|
|
|
closeModal()
|
|
|
|
|
|
await loadItems()
|
2026-05-07 14:10:26 +02:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
alert(e.message || String(e))
|
|
|
|
|
|
} finally {
|
2026-05-07 14:33:02 +02:00
|
|
|
|
setBusy(false)
|
2026-05-07 14:10:26 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 14:33:02 +02:00
|
|
|
|
const runLc = async (id, action, confirmMsg, extra = {}) => {
|
2026-05-07 14:10:26 +02:00
|
|
|
|
if (confirmMsg && !window.confirm(confirmMsg)) return
|
2026-05-07 14:33:02 +02:00
|
|
|
|
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') {
|
|
|
|
|
|
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)
|
2026-05-07 14:10:26 +02:00
|
|
|
|
try {
|
2026-05-07 14:33:02 +02:00
|
|
|
|
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()
|
2026-05-07 14:10:26 +02:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
alert(e.message || String(e))
|
|
|
|
|
|
} finally {
|
2026-05-07 14:33:02 +02:00
|
|
|
|
setBusy(false)
|
2026-05-07 14:10:26 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 14:33:02 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-05-07 21:36:35 +02:00
|
|
|
|
const onBulkArchiveFiles = async (e) => {
|
2026-05-08 08:35:15 +02:00
|
|
|
|
const input = e.target
|
|
|
|
|
|
const fl = input.files
|
2026-05-07 21:36:35 +02:00
|
|
|
|
if (!fl?.length) return
|
|
|
|
|
|
const list = Array.from(fl)
|
|
|
|
|
|
if (uploadVis === 'club' && !Number(uploadClubId)) {
|
|
|
|
|
|
window.alert('Bitte einen Verein für die Sichtbarkeit „Verein“ wählen.')
|
2026-05-08 08:35:15 +02:00
|
|
|
|
input.value = ''
|
2026-05-07 21:36:35 +02:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
setUploadBusy(true)
|
|
|
|
|
|
setUploadSummary('')
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await api.bulkUploadMediaAssets(list, {
|
|
|
|
|
|
visibility: uploadVis,
|
|
|
|
|
|
...(uploadVis === 'club' ? { club_id: Number(uploadClubId) } : {}),
|
|
|
|
|
|
})
|
|
|
|
|
|
setUploadSummary(
|
2026-05-07 22:02:18 +02:00
|
|
|
|
`Archiv-Upload: neu ${res.created_count}, bereits vorhanden ${res.duplicate_count}, fehlgeschlagen ${res.failed_count}. Liste aktualisiert.`,
|
2026-05-07 21:36:35 +02:00
|
|
|
|
)
|
2026-05-07 22:02:18 +02:00
|
|
|
|
if (res.created_count > 0 || res.duplicate_count > 0) {
|
|
|
|
|
|
setFilterUploaderId('')
|
|
|
|
|
|
}
|
2026-05-07 21:36:35 +02:00
|
|
|
|
await loadItems()
|
2026-05-07 22:02:18 +02:00
|
|
|
|
window.requestAnimationFrame(() => {
|
|
|
|
|
|
gridTopAnchorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
|
|
|
|
})
|
2026-05-07 21:36:35 +02:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
window.alert(err.message || String(err))
|
|
|
|
|
|
} finally {
|
2026-05-08 08:35:15 +02:00
|
|
|
|
input.value = ''
|
2026-05-07 21:36:35 +02:00
|
|
|
|
setUploadBusy(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 14:10:26 +02:00
|
|
|
|
return (
|
2026-05-07 14:33:02 +02:00
|
|
|
|
<div className="app-page media-library">
|
2026-05-07 14:10:26 +02:00
|
|
|
|
{isPlatformAdmin ? <AdminPageNav /> : null}
|
|
|
|
|
|
|
2026-05-07 14:33:02 +02:00
|
|
|
|
<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="/exercises">Übungen</Link>
|
|
|
|
|
|
{isPlatformAdmin ? <Link to="/admin/hierarchy">Admin</Link> : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="media-library__intro">
|
2026-05-07 16:15:07 +02:00
|
|
|
|
Veröffentlichte Medien (Verein/Plattform) und eigene Uploads. Suche durchsucht Bezeichner,
|
|
|
|
|
|
technischen Speicherpfad, Copyright-Text und Schlagwörter. Vorschau: Vorschaubild antippen.
|
|
|
|
|
|
Bearbeiten über das Menü — Bulk in der unteren Leiste.
|
2026-05-07 14:33:02 +02:00
|
|
|
|
</p>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="media-library__toolbar">
|
|
|
|
|
|
<div className="media-library__toolbar-row">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="search"
|
|
|
|
|
|
className="form-input media-library__search"
|
2026-05-07 16:15:07 +02:00
|
|
|
|
placeholder="Suche Bezeichner, Pfade, Copyright, Tags …"
|
2026-05-07 14:33:02 +02:00
|
|
|
|
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>
|
2026-05-07 16:15:07 +02:00
|
|
|
|
<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>
|
2026-05-07 21:36:35 +02:00
|
|
|
|
<div className="media-library__upload-row" aria-label="Archiv hochladen">
|
|
|
|
|
|
<input
|
|
|
|
|
|
ref={bulkFileInputRef}
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
className="media-library__sr-file"
|
2026-05-08 08:35:15 +02:00
|
|
|
|
accept={MEDIA_UPLOAD_ACCEPT}
|
2026-05-07 21:36:35 +02:00
|
|
|
|
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"
|
|
|
|
|
|
>
|
|
|
|
|
|
{VIS_OPTIONS.map((o) => (
|
|
|
|
|
|
<option key={o.value} value={o.value}>
|
|
|
|
|
|
Upload: {o.label}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
{uploadVis === 'club' ? (
|
|
|
|
|
|
<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>
|
2026-05-07 14:33:02 +02:00
|
|
|
|
<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>
|
2026-05-07 14:10:26 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-07 14:33:02 +02:00
|
|
|
|
{error ? (
|
|
|
|
|
|
<p className="media-library__err" role="alert">
|
|
|
|
|
|
{error}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
2026-05-07 14:10:26 +02:00
|
|
|
|
|
2026-05-07 22:02:18 +02:00
|
|
|
|
<div ref={gridTopAnchorRef} className="media-library__grid-top-anchor" aria-hidden="true" />
|
|
|
|
|
|
|
2026-05-07 14:33:02 +02:00
|
|
|
|
{loading && !items.length ? <div className="spinner media-library__spinner" /> : null}
|
2026-05-07 14:10:26 +02:00
|
|
|
|
|
2026-05-07 14:33:02 +02:00
|
|
|
|
{!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">
|
2026-05-07 14:10:26 +02:00
|
|
|
|
{items.map((it) => {
|
2026-05-07 14:33:02 +02:00
|
|
|
|
const chk = selected.has(it.id)
|
2026-05-07 14:10:26 +02:00
|
|
|
|
return (
|
2026-05-07 14:33:02 +02:00
|
|
|
|
<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>
|
2026-05-07 14:39:31 +02:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="media-library__card-thumb-hit"
|
|
|
|
|
|
onClick={() => setPreview(it)}
|
|
|
|
|
|
title="Vorschau"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="media-library__card-thumb-wrap">
|
2026-05-07 21:36:35 +02:00
|
|
|
|
<MediaTypeGlyph mimeType={it.mime_type} />
|
2026-05-07 14:39:31 +02:00
|
|
|
|
<MediaThumb mediaId={it.id} mimeType={it.mime_type} />
|
2026-05-07 16:15:07 +02:00
|
|
|
|
{(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}
|
2026-05-07 14:39:31 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</button>
|
2026-05-07 14:33:02 +02:00
|
|
|
|
<div className="media-library__card-footer">
|
|
|
|
|
|
<div className="media-library__card-name" title={it.original_filename || `#${it.id}`}>
|
|
|
|
|
|
{it.original_filename || `Medium #${it.id}`}
|
2026-05-07 14:10:26 +02:00
|
|
|
|
</div>
|
2026-05-07 16:15:07 +02:00
|
|
|
|
<div className="media-library__card-footer-row">
|
|
|
|
|
|
<MediaCardScopeStatus visibility={it.visibility} lifecycleState={it.lifecycle_state} />
|
2026-05-07 14:33:02 +02:00
|
|
|
|
</div>
|
2026-05-07 16:15:07 +02:00
|
|
|
|
{(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 />
|
2026-05-07 14:33:02 +02:00
|
|
|
|
</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>
|
2026-05-07 16:15:07 +02:00
|
|
|
|
<th>Kennzeichen</th>
|
|
|
|
|
|
<th className="media-library__th-tags">Tags</th>
|
|
|
|
|
|
<th className="media-library__th-usage">Verwendung</th>
|
2026-05-07 14:33:02 +02:00
|
|
|
|
{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">
|
2026-05-07 14:39:31 +02:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="media-library__table-thumb-hit"
|
|
|
|
|
|
onClick={() => setPreview(it)}
|
|
|
|
|
|
title="Vorschau"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="media-library__table-thumb">
|
2026-05-07 21:36:35 +02:00
|
|
|
|
<MediaTypeGlyph mimeType={it.mime_type} compact />
|
2026-05-07 14:39:31 +02:00
|
|
|
|
<MediaThumb mediaId={it.id} mimeType={it.mime_type} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</button>
|
2026-05-07 14:33:02 +02:00
|
|
|
|
</td>
|
|
|
|
|
|
<td className="media-library__td-name">{it.original_filename || `#${it.id}`}</td>
|
2026-05-07 16:15:07 +02:00
|
|
|
|
<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>
|
2026-05-07 14:33:02 +02:00
|
|
|
|
{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>
|
|
|
|
|
|
|
2026-05-07 14:39:31 +02:00
|
|
|
|
{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)
|
2026-05-07 21:55:22 +02:00
|
|
|
|
const mlow = (preview.mime_type || '').toLowerCase()
|
2026-05-07 14:39:31 +02:00
|
|
|
|
if (!url) {
|
|
|
|
|
|
return <p className="media-library__hint">Keine Datei-URL.</p>
|
|
|
|
|
|
}
|
2026-05-07 21:55:22 +02:00
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-05-07 14:39:31 +02:00
|
|
|
|
if (kind === 'image') {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<img
|
|
|
|
|
|
key={preview.id}
|
|
|
|
|
|
className="media-library__preview-img"
|
|
|
|
|
|
src={url}
|
|
|
|
|
|
alt=""
|
|
|
|
|
|
/>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (kind === 'video') {
|
2026-05-08 09:07:51 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<video
|
|
|
|
|
|
key={preview.id}
|
|
|
|
|
|
className="media-library__preview-video"
|
|
|
|
|
|
src={url}
|
|
|
|
|
|
controls
|
|
|
|
|
|
playsInline
|
|
|
|
|
|
preload="metadata"
|
|
|
|
|
|
>
|
|
|
|
|
|
Wiedergabe nicht unterstützt.
|
|
|
|
|
|
</video>
|
2026-05-07 14:39:31 +02:00
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
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}
|
|
|
|
|
|
|
2026-05-07 14:33:02 +02:00
|
|
|
|
{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)}>
|
|
|
|
|
|
{VIS_OPTIONS.map((o) => (
|
|
|
|
|
|
<option key={o.value} value={o.value}>
|
|
|
|
|
|
{o.label}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
{bulkVis === 'club' ? (
|
|
|
|
|
|
<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}</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) && (
|
|
|
|
|
|
<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 }))}
|
|
|
|
|
|
/>
|
2026-05-07 16:15:07 +02:00
|
|
|
|
<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"
|
|
|
|
|
|
/>
|
2026-05-07 14:33:02 +02:00
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<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 }))}
|
|
|
|
|
|
>
|
|
|
|
|
|
{VIS_OPTIONS.map((o) => (
|
|
|
|
|
|
<option key={o.value} value={o.value}>
|
|
|
|
|
|
{o.label}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
{modalDraft.visibility === 'club' ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<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}
|
|
|
|
|
|
|
2026-05-07 16:15:07 +02:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-05-07 14:33:02 +02:00
|
|
|
|
<div className="media-library__modal-actions">
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" disabled={busy} onClick={saveModal}>
|
|
|
|
|
|
Speichern
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</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?')
|
2026-05-07 14:10:26 +02:00
|
|
|
|
}
|
2026-05-07 14:33:02 +02:00
|
|
|
|
>
|
|
|
|
|
|
Papierkorb
|
|
|
|
|
|
</button>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{modal.permissions?.trash_hidden ? (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary"
|
2026-05-07 14:10:26 +02:00
|
|
|
|
disabled={busy}
|
2026-05-07 14:33:02 +02:00
|
|
|
|
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 ? (
|
2026-05-07 14:10:26 +02:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
|
disabled={busy}
|
2026-05-07 14:33:02 +02:00
|
|
|
|
onClick={() => runLc(modal.id, 'reactivate', null)}
|
2026-05-07 14:10:26 +02:00
|
|
|
|
>
|
2026-05-07 14:33:02 +02:00
|
|
|
|
Wieder aktiv
|
2026-05-07 14:10:26 +02:00
|
|
|
|
</button>
|
2026-05-07 14:33:02 +02:00
|
|
|
|
) : 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>
|
|
|
|
|
|
|
|
|
|
|
|
{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}
|
2026-05-07 14:10:26 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|