shinkan-jinkendo/frontend/src/pages/MediaLibraryPage.jsx

768 lines
27 KiB
React
Raw Normal View History

import { useEffect, useState, useCallback } from 'react'
import { Link } from 'react-router-dom'
import {
LayoutGrid,
List,
MoreVertical,
X,
} 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' },
]
function lcLabel(code) {
const o = LC_OPTIONS.find((x) => x.value === code)
return o ? o.label : code
}
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()
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>
}
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 loadClubs = useCallback(async () => {
try {
const c = await api.listClubs()
setClubs(Array.isArray(c) ? c : [])
} catch {
setClubs([])
}
}, [])
useEffect(() => {
loadClubs()
}, [loadClubs])
const loadItems = useCallback(async () => {
setLoading(true)
setError('')
try {
const res = await api.listMediaAssets({
lifecycle,
q: q.trim(),
limit: 100,
offset: 0,
})
setItems(res.items || [])
setViewer(res.viewer || null)
setSelected(new Set())
} catch (e) {
setError(e.message || String(e))
} finally {
setLoading(false)
}
}, [lifecycle, q])
useEffect(() => {
const t = setTimeout(() => {
loadItems()
}, 320)
return () => clearTimeout(t)
}, [lifecycle, q, loadItems])
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) => {
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) : '',
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
}
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
return (
<div className="app-page media-library">
{isPlatformAdmin ? <AdminPageNav /> : null}
<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">
Veröffentlichte Medien (Verein/Plattform) und eigene Uploads. Bearbeiten und Papierkorb über das
Menü pro Medium — Bulk unten in der Leiste.
</p>
</header>
<div className="media-library__toolbar">
<div className="media-library__toolbar-row">
<input
type="search"
className="form-input media-library__search"
placeholder="Suche Dateiname …"
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__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}
{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 lc = (it.lifecycle_state || '').toLowerCase()
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>
<div className="media-library__card-thumb-wrap">
<MediaThumb mediaId={it.id} mimeType={it.mime_type} />
</div>
<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-tags">
<span>{it.visibility}</span>
<span>{lcLabel(lc)}</span>
</div>
</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>Sichtbarkeit</th>
<th>Status</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) => {
const lc = (it.lifecycle_state || '').toLowerCase()
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">
<div className="media-library__table-thumb">
<MediaThumb mediaId={it.id} mimeType={it.mime_type} />
</div>
</td>
<td className="media-library__td-name">{it.original_filename || `#${it.id}`}</td>
<td>{it.visibility}</td>
<td>{lcLabel(lc)}</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>
{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 }))}
/>
</>
) : (
<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}
<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?')
}
>
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>
{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}
</div>
)
}