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 ? (
Trainings­einheiten{' '} {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.

setQ(e.target.value)} aria-label="Suche" />
{isSuperadmin ? ( ) : null} {viewer?.show_uploader_meta ? ( ) : null}
{uploadVis === 'club' ? ( ) : null} {uploadSummary ? ( {uploadSummary} ) : null}
{selCount > 0 ? ( ) : null}
{error ? (

{error}

) : null}