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
- 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.
368 lines
12 KiB
JavaScript
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>
|
|
)
|
|
}
|