import { useEffect, useState, useCallback, useRef, createElement } from 'react'
import { Link } from 'react-router-dom'
import {
LayoutGrid,
List,
MoreVertical,
X,
Globe,
Users,
Lock,
CheckCircle2,
Archive,
CircleDot,
FilePenLine,
Copyright,
Image as LucideImage,
Video as LucideVideo,
FileText,
File,
Upload,
} from 'lucide-react'
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' },
{ 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' },
]
/** 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'
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 (
·
)
}
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 {compact ? '—' : 'Noch in keiner Übung / Einheit verknüpft.'}
return (
{ex.length ? (
Übungen {' '}
{ex.map((e) => (
{e.title.length > (compact ? 18 : 40) ? `${e.title.slice(0, compact ? 18 : 40)}…` : e.title}
))}
) : null}
{tus.length ? (
Trainingseinheiten {' '}
{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 (
{short}
)
})}
) : null}
)
}
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 HEIC
}
if (mime.startsWith('video/')) {
return createElement('video', {
className: 'media-library__thumb-video',
src: url,
muted: true,
playsInline: true,
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 (
{
e.target.style.display = 'none'
}}
/>
)
}
if (mime.includes('pdf')) {
return PDF
}
return ◆
}
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 = LucideImage
else if (kind === 'video') Icon = LucideVideo
else if (kind === 'pdf') Icon = FileText
const sz = compact ? 12 : 14
return (
)
}
export default function MediaLibraryPage() {
const { user } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const isSuperadmin = user?.role === 'superadmin'
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('')
const mediaListFetchSeqRef = useRef(0)
const gridTopAnchorRef = useRef(null)
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 saveModal = async () => {
if (!modal || !modalDraft) return
const p = modal.permissions || {}
setBusy(true)
try {
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') {
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()
} 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') {
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 = async (e) => {
const input = e.target
const fl = input.files
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.')
input.value = ''
return
}
setUploadBusy(true)
setUploadSummary('')
try {
const res = await api.bulkUploadMediaAssets(list, {
visibility: uploadVis,
...(uploadVis === 'club' ? { club_id: Number(uploadClubId) } : {}),
})
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 {
input.value = ''
setUploadBusy(false)
}
}
return (
{isPlatformAdmin ?
: null}
Medienbibliothek
Übungen
{isPlatformAdmin ? Admin : null}
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.
{error ? (
{error}
) : null}
{loading && !items.length ?
: null}
{!loading && !items.length && !error ? (
Keine Medien für diese Filter.
) : null}
{viewMode === 'grid' && items.length > 0 ? (
{items.map((it) => {
const chk = selected.has(it.id)
return (
toggleSel(it.id)} />
openEdit(it)}
title="Bearbeiten"
aria-label="Bearbeiten"
>
setPreview(it)}
title="Vorschau"
>
{(it.copyright_notice || '').trim() ? (
) : null}
{it.original_filename || `Medium #${it.id}`}
{(it.tags || []).length ? (
{(it.tags || []).slice(0, 6).map((tg) => (
{tg}
))}
) : null}
)
})}
) : null}
{viewMode === 'list' && items.length > 0 ? (
) : null}
{preview ? (
setPreview(null)}
>
e.stopPropagation()}
>
{
openEdit(preview)
}}
>
Bearbeiten
setPreview(null)}
aria-label="Vorschau schließen"
>
{(() => {
const url = resolveMediaAssetFileUrl(preview.id)
const kind = previewDisplayKind(preview.mime_type)
const mlow = (preview.mime_type || '').toLowerCase()
if (!url) {
return
Keine Datei-URL.
}
if (mlow.includes('heic') || mlow.includes('heif')) {
return (
HEIC/HEIF — in diesem Browser oft keine eingebettete Vorschau. Zum Ansehen herunterladen oder im neuen Tab öffnen.
Datei öffnen
)
}
if (kind === 'image') {
return (
)
}
if (kind === 'video') {
return createElement(
'video',
{
key: preview.id,
className: 'media-library__preview-video',
src: url,
controls: true,
playsInline: true,
preload: 'metadata',
},
'Wiedergabe nicht unterstützt.',
)
}
if (kind === 'pdf') {
return (
)
}
return (
Vorschau für diesen Typ nicht verfügbar ({preview.mime_type || 'unbekannt'}).
Datei öffnen
)
})()}
) : null}
{bulkOpen ? (
Bulk für {selCount} Medien
setBulkOpen(false)} aria-label="Schließen">
Nur ausgefüllte Felder werden gesetzt. Fehlende Rechte pro Medium werden im Ergebnis gemeldet.
Copyright (optional)
) : null}
{modal && modalDraft ? (
{(viewer?.show_club_meta || viewer?.show_uploader_meta) && (
{viewer?.show_club_meta ? (
Verein
{modal.club_name || modal.club_id || '—'}
) : null}
{viewer?.show_uploader_meta ? (
Uploader
{uploaderLabel(modal, viewer) || '—'}
) : null}
Technisch
{modal.mime_type || '—'} · ID {modal.id}
{isSuperadmin && modal.storage_key ? ` · ${modal.storage_key}` : ''}
)}
{modal.permissions?.edit_metadata ? (
<>
Bezeichnung (Dateiname / Anzeige)
setModalDraft((d) => ({ ...d, display_name: e.target.value }))}
/>
Copyright
) : null}
)
}