309 lines
10 KiB
React
309 lines
10 KiB
React
|
|
/**
|
||
|
|
* Modal: Medium aus Archiv verknüpfen oder neue Datei hochladen, dann Inline-Platzhalter §11 einfügen.
|
||
|
|
*/
|
||
|
|
import React, { useEffect, useState, useCallback } from 'react'
|
||
|
|
import api from '../utils/api'
|
||
|
|
import {
|
||
|
|
INLINE_MEDIA_SIZES,
|
||
|
|
DEFAULT_INLINE_MEDIA_SIZE,
|
||
|
|
sanitizeInlineMediaSize,
|
||
|
|
} from '../constants/inlineExerciseMedia'
|
||
|
|
|
||
|
|
/** MIME/Dateiname → Übungs-media_type */
|
||
|
|
function inferExerciseMediaType(file) {
|
||
|
|
if (!file) return 'image'
|
||
|
|
const mime = (file.type || '').toLowerCase()
|
||
|
|
if (mime.startsWith('image/')) return 'image'
|
||
|
|
if (mime.startsWith('video/')) return 'video'
|
||
|
|
if (mime === 'application/pdf' || mime.includes('pdf')) return 'document'
|
||
|
|
const name = (file.name || '').toLowerCase()
|
||
|
|
if (/\.(mp4|webm|mov|mkv|avi|m4v|mpeg|mpg)$/.test(name)) return 'video'
|
||
|
|
if (/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/.test(name)) return 'image'
|
||
|
|
if (/\.pdf$/.test(name)) return 'document'
|
||
|
|
return 'image'
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {{
|
||
|
|
* open: boolean,
|
||
|
|
* onClose: () => void,
|
||
|
|
* exerciseId: number,
|
||
|
|
* onMediaListChanged: () => Promise<void>,
|
||
|
|
* onInserted: (exerciseMediaId: number, displaySize: string) => void,
|
||
|
|
* }} props
|
||
|
|
*/
|
||
|
|
export default function ExerciseInlineFileMediaModal({
|
||
|
|
open,
|
||
|
|
onClose,
|
||
|
|
exerciseId,
|
||
|
|
onMediaListChanged,
|
||
|
|
onInserted,
|
||
|
|
}) {
|
||
|
|
const [tab, setTab] = useState('library')
|
||
|
|
const [q, setQ] = useState('')
|
||
|
|
const [loading, setLoading] = useState(false)
|
||
|
|
const [items, setItems] = useState([])
|
||
|
|
const [err, setErr] = useState(null)
|
||
|
|
const [selectedAssetId, setSelectedAssetId] = useState(null)
|
||
|
|
const [busy, setBusy] = useState(false)
|
||
|
|
const [uploadFile, setUploadFile] = useState(null)
|
||
|
|
const [uploadTitle, setUploadTitle] = useState('')
|
||
|
|
const [displaySize, setDisplaySize] = useState(DEFAULT_INLINE_MEDIA_SIZE)
|
||
|
|
|
||
|
|
const loadAssets = useCallback(async () => {
|
||
|
|
setLoading(true)
|
||
|
|
setErr(null)
|
||
|
|
try {
|
||
|
|
const res = await api.listMediaAssets({
|
||
|
|
q: q.trim() || undefined,
|
||
|
|
limit: 48,
|
||
|
|
lifecycle: 'active',
|
||
|
|
})
|
||
|
|
setItems(Array.isArray(res.items) ? res.items : [])
|
||
|
|
} catch (e) {
|
||
|
|
setErr(e.message || String(e))
|
||
|
|
setItems([])
|
||
|
|
} finally {
|
||
|
|
setLoading(false)
|
||
|
|
}
|
||
|
|
}, [q])
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!open) return undefined
|
||
|
|
setTab('library')
|
||
|
|
setSelectedAssetId(null)
|
||
|
|
setUploadFile(null)
|
||
|
|
setUploadTitle('')
|
||
|
|
setDisplaySize(DEFAULT_INLINE_MEDIA_SIZE)
|
||
|
|
setErr(null)
|
||
|
|
const t = setTimeout(loadAssets, 280)
|
||
|
|
return () => clearTimeout(t)
|
||
|
|
}, [open])
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!open || tab !== 'library') return undefined
|
||
|
|
const t = setTimeout(loadAssets, 300)
|
||
|
|
return () => clearTimeout(t)
|
||
|
|
}, [q, open, tab, loadAssets])
|
||
|
|
|
||
|
|
const handleLinkSelected = async () => {
|
||
|
|
if (!selectedAssetId) {
|
||
|
|
alert('Bitte ein Archiv-Medium auswählen.')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
const size = sanitizeInlineMediaSize(displaySize)
|
||
|
|
setBusy(true)
|
||
|
|
setErr(null)
|
||
|
|
try {
|
||
|
|
const row = await api.attachExerciseMediaFromAsset(exerciseId, {
|
||
|
|
media_asset_id: selectedAssetId,
|
||
|
|
title: '',
|
||
|
|
description: '',
|
||
|
|
context: 'ablauf',
|
||
|
|
is_primary: false,
|
||
|
|
})
|
||
|
|
const mid = row?.id
|
||
|
|
if (mid == null) {
|
||
|
|
throw new Error('Antwort ohne exercise_media-ID')
|
||
|
|
}
|
||
|
|
await onMediaListChanged()
|
||
|
|
onInserted(Number(mid), size)
|
||
|
|
onClose()
|
||
|
|
} catch (e) {
|
||
|
|
const msg = e.message || String(e)
|
||
|
|
setErr(msg)
|
||
|
|
alert(msg)
|
||
|
|
} finally {
|
||
|
|
setBusy(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleUploadAndInsert = async () => {
|
||
|
|
if (!uploadFile) {
|
||
|
|
alert('Bitte eine Datei wählen.')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
const size = sanitizeInlineMediaSize(displaySize)
|
||
|
|
const inferred = inferExerciseMediaType(uploadFile)
|
||
|
|
const fd = new FormData()
|
||
|
|
fd.append('file', uploadFile)
|
||
|
|
fd.append('media_type', inferred)
|
||
|
|
fd.append('title', uploadTitle.trim())
|
||
|
|
fd.append('description', '')
|
||
|
|
fd.append('context', 'ablauf')
|
||
|
|
fd.append('is_primary', 'false')
|
||
|
|
setBusy(true)
|
||
|
|
setErr(null)
|
||
|
|
try {
|
||
|
|
const row = await api.uploadExerciseMedia(exerciseId, fd)
|
||
|
|
const mid = row?.id
|
||
|
|
if (mid == null) {
|
||
|
|
throw new Error('Antwort ohne exercise_media-ID')
|
||
|
|
}
|
||
|
|
await onMediaListChanged()
|
||
|
|
onInserted(Number(mid), size)
|
||
|
|
setUploadFile(null)
|
||
|
|
setUploadTitle('')
|
||
|
|
onClose()
|
||
|
|
} catch (e) {
|
||
|
|
if (e.code === 'MEDIA_ASSET_IN_TRASH' && e.payload?.media_asset_id != null) {
|
||
|
|
alert(
|
||
|
|
'Dieselbe Datei existiert bereits im Papierkorb — bitte in der Medienbibliothek reaktivieren oder eine andere Datei wählen.',
|
||
|
|
)
|
||
|
|
} else {
|
||
|
|
alert(e.message || String(e))
|
||
|
|
}
|
||
|
|
setErr(e.message || String(e))
|
||
|
|
} finally {
|
||
|
|
setBusy(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!open) return null
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="admin-modal-backdrop" role="presentation" onClick={(e) => e.target === e.currentTarget && !busy && onClose()}>
|
||
|
|
<div
|
||
|
|
className="admin-modal-sheet rte-inline-media-modal"
|
||
|
|
role="dialog"
|
||
|
|
aria-modal="true"
|
||
|
|
aria-labelledby="rte-inline-file-title"
|
||
|
|
style={{
|
||
|
|
maxWidth: '560px',
|
||
|
|
width: '100%',
|
||
|
|
maxHeight: '90vh',
|
||
|
|
display: 'flex',
|
||
|
|
flexDirection: 'column',
|
||
|
|
}}
|
||
|
|
onClick={(e) => e.stopPropagation()}
|
||
|
|
>
|
||
|
|
<div className="admin-modal-sheet__header">
|
||
|
|
<h3 id="rte-inline-file-title" className="admin-modal-sheet__title">
|
||
|
|
Medium im Textfeld
|
||
|
|
</h3>
|
||
|
|
<button type="button" className="btn btn-secondary admin-modal-sheet__close" disabled={busy} onClick={onClose}>
|
||
|
|
Schließen
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div style={{ padding: '10px 12px', borderBottom: '1px solid var(--border)', display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className={`btn btn-secondary ${tab === 'library' ? 'rte-tab--active' : ''}`}
|
||
|
|
style={{ fontSize: '13px' }}
|
||
|
|
onClick={() => setTab('library')}
|
||
|
|
disabled={busy}
|
||
|
|
>
|
||
|
|
Aus Mediathek
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className={`btn btn-secondary ${tab === 'upload' ? 'rte-tab--active' : ''}`}
|
||
|
|
style={{ fontSize: '13px' }}
|
||
|
|
onClick={() => setTab('upload')}
|
||
|
|
disabled={busy}
|
||
|
|
>
|
||
|
|
Neu hochladen
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div style={{ overflowY: 'auto', flex: 1, padding: '12px 14px' }}>
|
||
|
|
{tab === 'library' && (
|
||
|
|
<>
|
||
|
|
<label className="form-label">Suche in der Bibliothek</label>
|
||
|
|
<input
|
||
|
|
className="form-input"
|
||
|
|
value={q}
|
||
|
|
onChange={(e) => setQ(e.target.value)}
|
||
|
|
placeholder="Name, Tag, © …"
|
||
|
|
disabled={busy}
|
||
|
|
/>
|
||
|
|
{loading ? <p style={{ color: 'var(--text3)', marginTop: '12px' }}>Laden…</p> : null}
|
||
|
|
{err && tab === 'library' && !loading ? (
|
||
|
|
<p style={{ color: 'var(--danger)', marginTop: '10px', fontSize: '0.9rem' }}>{err}</p>
|
||
|
|
) : null}
|
||
|
|
<div className="rte-inline-asset-grid" style={{ marginTop: '14px' }}>
|
||
|
|
{items.map((it) => {
|
||
|
|
const id = it.id
|
||
|
|
const selected = selectedAssetId === id
|
||
|
|
const label = it.original_filename || it.copyright_notice || `Archiv #${id}`
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
key={id}
|
||
|
|
type="button"
|
||
|
|
className={`rte-inline-asset-tile${selected ? ' rte-inline-asset-tile--selected' : ''}`}
|
||
|
|
onClick={() => setSelectedAssetId(id)}
|
||
|
|
disabled={busy}
|
||
|
|
>
|
||
|
|
<span className="rte-inline-asset-tile__meta">
|
||
|
|
{(it.mime_type || '').split('/')[0] || 'datei'}
|
||
|
|
</span>
|
||
|
|
<span className="rte-inline-asset-tile__name">{label}</span>
|
||
|
|
</button>
|
||
|
|
)
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
{!loading && items.length === 0 ? (
|
||
|
|
<p style={{ color: 'var(--text3)', marginTop: '12px' }}>Keine Treffer — Suche anpassen oder „Neu hochladen“.</p>
|
||
|
|
) : null}
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{tab === 'upload' && (
|
||
|
|
<>
|
||
|
|
<label className="form-label">Datei</label>
|
||
|
|
<input
|
||
|
|
type="file"
|
||
|
|
accept="image/*,video/*,application/pdf"
|
||
|
|
className="form-input"
|
||
|
|
disabled={busy}
|
||
|
|
onChange={(e) => {
|
||
|
|
const f = e.target.files?.[0] || null
|
||
|
|
setUploadFile(f)
|
||
|
|
e.target.value = ''
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
{uploadFile ? (
|
||
|
|
<p style={{ fontSize: '13px', color: 'var(--text2)', marginTop: '8px' }}>{uploadFile.name}</p>
|
||
|
|
) : null}
|
||
|
|
<label className="form-label" style={{ marginTop: '12px' }}>
|
||
|
|
Titel (optional)
|
||
|
|
</label>
|
||
|
|
<input
|
||
|
|
className="form-input"
|
||
|
|
value={uploadTitle}
|
||
|
|
onChange={(e) => setUploadTitle(e.target.value)}
|
||
|
|
disabled={busy}
|
||
|
|
/>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div style={{ padding: '12px 14px', borderTop: '1px solid var(--border)', flexShrink: 0 }}>
|
||
|
|
<div className="form-row" style={{ marginBottom: '12px' }}>
|
||
|
|
<label className="form-label" style={{ marginBottom: '4px' }}>
|
||
|
|
Darstellung im Text
|
||
|
|
</label>
|
||
|
|
<select className="form-input" value={displaySize} onChange={(e) => setDisplaySize(e.target.value)} disabled={busy}>
|
||
|
|
{INLINE_MEDIA_SIZES.map((o) => (
|
||
|
|
<option key={o.value} value={o.value}>
|
||
|
|
{o.label}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
{tab === 'library' ? (
|
||
|
|
<button type="button" className="btn btn-primary btn-full" disabled={busy || !selectedAssetId} onClick={handleLinkSelected}>
|
||
|
|
{busy ? 'Verknüpfen…' : 'Verknüpfen & in Text einfügen'}
|
||
|
|
</button>
|
||
|
|
) : (
|
||
|
|
<button type="button" className="btn btn-primary btn-full" disabled={busy || !uploadFile} onClick={handleUploadAndInsert}>
|
||
|
|
{busy ? 'Hochladen…' : 'Hochladen & in Text einfügen'}
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|