shinkan-jinkendo/frontend/src/pages/MediaLibraryPage.jsx
Lars 0a1816e38b
All checks were successful
Deploy Development / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 23s
feat: enhance media library and lifecycle management
- Updated media library to include lifecycle filtering options (active, trash_soft, trash_hidden) and copyright management capabilities.
- Implemented new API endpoints for listing media assets with lifecycle states and patching copyright notices.
- Enhanced frontend components to support navigation to the media library and integration of media management features in the ExerciseFormPage.
- Incremented version to 0.8.48, reflecting the latest improvements in media handling and governance.
2026-05-07 14:10:26 +02:00

368 lines
12 KiB
JavaScript

import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
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 (Stufe 1)' },
{ value: 'trash_hidden', label: 'Ausgeblendet (Stufe 2)' },
{ value: 'all', label: 'Alle (nicht purgiert)' },
]
function lcLabel(code) {
const o = LC_OPTIONS.find((x) => x.value === code)
return o ? o.label : code
}
export default function MediaLibraryPage() {
const { user } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const [lifecycle, setLifecycle] = useState('active')
const [q, setQ] = useState('')
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
/** @type {Record<number, string>} */
const [copyrightDrafts, setCopyrightDrafts] = useState({})
const [busyId, setBusyId] = useState(null)
useEffect(() => {
let cancelled = false
const t = setTimeout(async () => {
setLoading(true)
setError('')
try {
const res = await api.listMediaAssets({
lifecycle,
q: q.trim(),
limit: 100,
offset: 0,
})
if (cancelled) return
setItems(res.items || [])
const nx = {}
for (const it of res.items || []) {
nx[it.id] = it.copyright_notice != null ? String(it.copyright_notice) : ''
}
setCopyrightDrafts(nx)
} catch (e) {
if (!cancelled) setError(e.message || String(e))
} finally {
if (!cancelled) setLoading(false)
}
}, 320)
return () => {
cancelled = true
clearTimeout(t)
}
}, [lifecycle, q])
const refresh = async () => {
setLoading(true)
setError('')
try {
const res = await api.listMediaAssets({
lifecycle,
q: q.trim(),
limit: 100,
offset: 0,
})
setItems(res.items || [])
const nx = {}
for (const it of res.items || []) {
nx[it.id] = it.copyright_notice != null ? String(it.copyright_notice) : ''
}
setCopyrightDrafts(nx)
} catch (e) {
setError(e.message || String(e))
} finally {
setLoading(false)
}
}
const saveCopyright = async (id) => {
const text = copyrightDrafts[id]
if (text === undefined) return
setBusyId(id)
try {
await api.patchMediaAsset(id, { copyright_notice: text })
await refresh()
} catch (e) {
alert(e.message || String(e))
} finally {
setBusyId(null)
}
}
const runLifecycle = async (id, action, confirmMsg) => {
if (confirmMsg && !window.confirm(confirmMsg)) return
setBusyId(id)
try {
await api.postMediaAssetLifecycle(id, action)
await refresh()
} catch (e) {
alert(e.message || String(e))
} finally {
setBusyId(null)
}
}
return (
<div className="app-page media-library-page">
{isPlatformAdmin ? <AdminPageNav /> : null}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'baseline',
gap: '12px',
marginBottom: '12px',
}}
>
<h1 className="page-title" style={{ margin: 0 }}>
Medienbibliothek
</h1>
<Link to="/exercises" style={{ fontSize: '14px' }}>
Zu den Übungen
</Link>
{isPlatformAdmin ? (
<Link to="/admin/hierarchy" style={{ fontSize: '14px' }}>
Administration
</Link>
) : null}
</div>
<p style={{ color: 'var(--text2)', fontSize: '14px', maxWidth: '52rem', marginTop: 0 }}>
Sichtbare Medien gemäß deinen Rechten (privat, Verein, offiziell). Papierkorb- und
Metadaten-Aktionen wie in der Übungsbearbeitung hier zentral mit Filter.
</p>
<div
className="form-row"
style={{ flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end', marginBottom: '16px' }}
>
<div>
<label className="form-label" htmlFor="media-lib-q">
Suche
</label>
<input
id="media-lib-q"
type="search"
className="form-input"
placeholder="Dateiname …"
value={q}
onChange={(e) => setQ(e.target.value)}
style={{ minWidth: '220px' }}
/>
</div>
<div>
<label className="form-label" htmlFor="media-lib-lc">
Lebenszyklus
</label>
<select
id="media-lib-lc"
className="form-input"
value={lifecycle}
onChange={(e) => setLifecycle(e.target.value)}
>
{LC_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<button type="button" className="btn btn-secondary" onClick={refresh} disabled={loading}>
Aktualisieren
</button>
</div>
{error ? (
<p style={{ color: 'var(--danger, crimson)' }} role="alert">
{error}
</p>
) : null}
{loading && items.length === 0 ? <div className="spinner" /> : null}
{!loading && !error && items.length === 0 ? (
<p style={{ color: 'var(--text2)' }}>Keine Einträge.</p>
) : null}
<div style={{ overflowX: 'auto' }}>
<table className="table" style={{ width: '100%', fontSize: '14px' }}>
<thead>
<tr>
<th style={{ width: 56 }} aria-hidden />
<th>Datei</th>
<th>Sichtbarkeit</th>
<th>Status</th>
<th style={{ minWidth: '220px' }}>Copyright</th>
<th style={{ minWidth: '200px' }}>Aktionen</th>
</tr>
</thead>
<tbody>
{items.map((it) => {
const mime = (it.mime_type || '').toLowerCase()
const isImg = mime.startsWith('image/')
const busy = busyId === it.id
const lc = (it.lifecycle_state || '').toLowerCase()
return (
<tr key={it.id}>
<td>
{isImg ? (
<img
src={resolveMediaAssetFileUrl(it.id)}
alt=""
width={48}
height={48}
loading="lazy"
style={{
objectFit: 'cover',
borderRadius: 6,
background: 'var(--surface2, #eee)',
}}
onError={(e) => {
e.target.style.visibility = 'hidden'
}}
/>
) : (
<div
style={{
width: 48,
height: 48,
borderRadius: 6,
background: 'var(--surface2, #eee)',
fontSize: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text2)',
}}
>
{mime.includes('video') ? '▶' : '◆'}
</div>
)}
</td>
<td>
<div style={{ fontWeight: 600 }}>{it.original_filename || `Asset #${it.id}`}</div>
<div style={{ fontSize: '12px', color: 'var(--text2)' }}>
ID {it.id}
{it.byte_size != null ? ` · ${Math.round(it.byte_size / 1024)} KB` : ''}
</div>
</td>
<td>{it.visibility}</td>
<td>{lcLabel(lc)}</td>
<td>
<textarea
className="form-input"
rows={2}
style={{ width: '100%', resize: 'vertical', minHeight: '48px' }}
value={copyrightDrafts[it.id] ?? ''}
onChange={(e) =>
setCopyrightDrafts((prev) => ({ ...prev, [it.id]: e.target.value }))
}
disabled={busy}
/>
<button
type="button"
className="btn btn-secondary"
style={{ marginTop: 6 }}
disabled={busy}
onClick={() => saveCopyright(it.id)}
>
Copyright speichern
</button>
</td>
<td>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{lc === 'active' ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() =>
runLifecycle(
it.id,
'trash_soft',
'Medium in den Papierkorb (Stufe 1) legen?',
)
}
>
Papierkorb
</button>
) : null}
{lc === 'trash_soft' ? (
<>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() => runLifecycle(it.id, 'reactivate', null)}
>
Wieder aktiv
</button>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() =>
runLifecycle(
it.id,
'trash_hidden',
'Medium ausblenden (Stufe 2)? In öffentlichen Ansichten unsichtbar.',
)
}
>
Ausblenden
</button>
</>
) : null}
{lc === 'trash_hidden' ? (
<>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() => runLifecycle(it.id, 'recover', null)}
>
Zurück Stufe 1
</button>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() => runLifecycle(it.id, 'reactivate', null)}
>
Wieder aktiv
</button>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() =>
runLifecycle(
it.id,
'purge',
'Endgültig löschen (Datei und DB-Eintrag)?',
)
}
>
Endgültig löschen
</button>
</>
) : null}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}