2026-05-08 12:00:02 +02:00
|
|
|
/**
|
|
|
|
|
* Modal: Medium aus Archiv verknüpfen oder neue Datei hochladen, dann Inline-Platzhalter §11 einfügen.
|
|
|
|
|
*/
|
2026-05-08 12:20:24 +02:00
|
|
|
import React, { useEffect, useState, useCallback, useMemo } from 'react'
|
2026-05-08 12:00:02 +02:00
|
|
|
import api from '../utils/api'
|
2026-05-08 12:20:24 +02:00
|
|
|
import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
|
2026-05-08 12:00:02 +02:00
|
|
|
import {
|
|
|
|
|
INLINE_MEDIA_SIZES,
|
|
|
|
|
DEFAULT_INLINE_MEDIA_SIZE,
|
|
|
|
|
sanitizeInlineMediaSize,
|
|
|
|
|
} from '../constants/inlineExerciseMedia'
|
2026-05-08 12:20:24 +02:00
|
|
|
import { sanitizeInlineMediaCaption } from '../utils/inlineMediaCaption'
|
2026-05-08 12:00:02 +02:00
|
|
|
|
2026-05-08 12:35:28 +02:00
|
|
|
function RtePickerAssetThumb({ asset }) {
|
|
|
|
|
const id = asset.id
|
|
|
|
|
const src = resolveMediaAssetFileUrl(id)
|
|
|
|
|
const mt = (asset.mime_type || '').toLowerCase()
|
|
|
|
|
if (mt.startsWith('image/') && src) {
|
|
|
|
|
return <img alt="" src={src} className="rte-inline-asset-tile__thumb-img" />
|
|
|
|
|
}
|
|
|
|
|
if (mt.startsWith('video/') && src) {
|
|
|
|
|
return (
|
|
|
|
|
<video
|
|
|
|
|
key={`v-${id}`}
|
|
|
|
|
className="rte-inline-asset-tile__thumb-video"
|
|
|
|
|
src={src}
|
|
|
|
|
muted
|
|
|
|
|
playsInline
|
|
|
|
|
preload="metadata"
|
|
|
|
|
onLoadedMetadata={(e) => {
|
|
|
|
|
try {
|
|
|
|
|
const el = e.currentTarget
|
|
|
|
|
const d = el.duration
|
|
|
|
|
el.currentTime = Number.isFinite(d) && d > 0 ? Math.min(0.05, d * 0.01) : 0.05
|
|
|
|
|
} catch (_) {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
const nameLow = String(asset.original_filename || '').toLowerCase()
|
|
|
|
|
if (mt.includes('pdf') || nameLow.endsWith('.pdf')) {
|
|
|
|
|
return <span className="rte-inline-asset-tile__thumb-fallback">PDF</span>
|
|
|
|
|
}
|
|
|
|
|
return <span className="rte-inline-asset-tile__thumb-fallback">Datei</span>
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 12:00:02 +02:00
|
|
|
/** 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,
|
2026-05-08 12:20:24 +02:00
|
|
|
* linkedExerciseMedia?: object[],
|
2026-05-08 12:00:02 +02:00
|
|
|
* onMediaListChanged: () => Promise<void>,
|
2026-05-08 12:20:24 +02:00
|
|
|
* onInserted: (exerciseMediaId: number, displaySize: string, caption?: string) => void,
|
2026-05-08 12:00:02 +02:00
|
|
|
* }} props
|
|
|
|
|
*/
|
|
|
|
|
export default function ExerciseInlineFileMediaModal({
|
|
|
|
|
open,
|
|
|
|
|
onClose,
|
|
|
|
|
exerciseId,
|
2026-05-08 12:20:24 +02:00
|
|
|
linkedExerciseMedia = [],
|
2026-05-08 12:00:02 +02:00
|
|
|
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)
|
2026-05-08 12:20:24 +02:00
|
|
|
const [uploadInputKey, setUploadInputKey] = useState(0)
|
|
|
|
|
|
|
|
|
|
const assetToExerciseMedia = useMemo(() => {
|
|
|
|
|
const m = new Map()
|
|
|
|
|
for (const row of linkedExerciseMedia || []) {
|
|
|
|
|
const aid = row?.media_asset_id
|
|
|
|
|
if (aid != null) m.set(Number(aid), row)
|
|
|
|
|
}
|
|
|
|
|
return m
|
|
|
|
|
}, [linkedExerciseMedia])
|
2026-05-08 12:00:02 +02:00
|
|
|
|
|
|
|
|
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('')
|
2026-05-08 12:20:24 +02:00
|
|
|
setUploadInputKey((k) => k + 1)
|
2026-05-08 12:00:02 +02:00
|
|
|
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)
|
2026-05-08 12:20:24 +02:00
|
|
|
const assetMeta = items.find((x) => x.id === selectedAssetId)
|
|
|
|
|
const capFromExisting = (row) =>
|
|
|
|
|
sanitizeInlineMediaCaption(row?.original_filename || row?.title || assetMeta?.original_filename || '')
|
|
|
|
|
|
|
|
|
|
const existing = assetToExerciseMedia.get(Number(selectedAssetId))
|
|
|
|
|
if (existing?.id != null) {
|
|
|
|
|
onInserted(Number(existing.id), size, capFromExisting(existing))
|
|
|
|
|
onClose()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 12:00:02 +02:00
|
|
|
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()
|
2026-05-08 12:20:24 +02:00
|
|
|
onInserted(
|
|
|
|
|
Number(mid),
|
|
|
|
|
size,
|
|
|
|
|
sanitizeInlineMediaCaption(assetMeta?.original_filename || ''),
|
|
|
|
|
)
|
2026-05-08 12:00:02 +02:00
|
|
|
onClose()
|
|
|
|
|
} catch (e) {
|
|
|
|
|
const msg = e.message || String(e)
|
|
|
|
|
setErr(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()
|
2026-05-08 12:20:24 +02:00
|
|
|
const cap = sanitizeInlineMediaCaption(
|
|
|
|
|
uploadTitle.trim() || uploadFile.name || '',
|
|
|
|
|
)
|
|
|
|
|
onInserted(Number(mid), size, cap)
|
2026-05-08 12:00:02 +02:00
|
|
|
setUploadFile(null)
|
|
|
|
|
setUploadTitle('')
|
2026-05-08 12:20:24 +02:00
|
|
|
setUploadInputKey((k) => k + 1)
|
2026-05-08 12:00:02 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 12:20:24 +02:00
|
|
|
const selectedLinked = selectedAssetId != null && assetToExerciseMedia.has(Number(selectedAssetId))
|
|
|
|
|
|
2026-05-08 12:00:02 +02:00
|
|
|
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' }}>
|
2026-05-08 12:20:24 +02:00
|
|
|
{err && !loading ? (
|
|
|
|
|
<p style={{ color: 'var(--danger)', marginTop: '0', marginBottom: '12px', fontSize: '0.9rem' }}>{err}</p>
|
|
|
|
|
) : null}
|
2026-05-08 12:00:02 +02:00
|
|
|
{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}
|
|
|
|
|
<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}`
|
2026-05-08 12:20:24 +02:00
|
|
|
const linked = assetToExerciseMedia.has(Number(id))
|
2026-05-08 12:00:02 +02:00
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={id}
|
|
|
|
|
type="button"
|
|
|
|
|
className={`rte-inline-asset-tile${selected ? ' rte-inline-asset-tile--selected' : ''}`}
|
|
|
|
|
onClick={() => setSelectedAssetId(id)}
|
|
|
|
|
disabled={busy}
|
|
|
|
|
>
|
2026-05-08 12:20:24 +02:00
|
|
|
<div className="rte-inline-asset-tile__thumb" aria-hidden>
|
2026-05-08 12:35:28 +02:00
|
|
|
<RtePickerAssetThumb asset={it} />
|
2026-05-08 12:20:24 +02:00
|
|
|
</div>
|
|
|
|
|
{linked ? (
|
|
|
|
|
<span className="rte-inline-asset-tile__badge">Bereits verknüpft</span>
|
|
|
|
|
) : null}
|
2026-05-08 12:00:02 +02:00
|
|
|
<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>
|
2026-05-08 12:20:24 +02:00
|
|
|
<div className="rte-inline-file-row">
|
|
|
|
|
<input
|
|
|
|
|
key={uploadInputKey}
|
|
|
|
|
id="rte-inline-file-upload-input"
|
|
|
|
|
type="file"
|
|
|
|
|
accept="image/*,video/*,application/pdf"
|
|
|
|
|
className="rte-inline-file-input-hidden"
|
|
|
|
|
disabled={busy}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const f = e.target.files?.[0] || null
|
|
|
|
|
setUploadFile(f)
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<label htmlFor="rte-inline-file-upload-input" className="btn btn-secondary rte-inline-file-pick-btn">
|
|
|
|
|
Datei auswählen
|
|
|
|
|
</label>
|
|
|
|
|
<span className="rte-inline-file-name" title={uploadFile?.name || ''}>
|
|
|
|
|
{uploadFile ? uploadFile.name : 'Keine Datei ausgewählt'}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2026-05-08 12:00:02 +02:00
|
|
|
<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' ? (
|
2026-05-08 12:20:24 +02:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="btn btn-primary btn-full"
|
|
|
|
|
disabled={busy || !selectedAssetId}
|
|
|
|
|
onClick={handleLinkSelected}
|
|
|
|
|
>
|
|
|
|
|
{busy ? 'Bitte warten…' : selectedLinked ? 'In Text einfügen (bereits verknüpft)' : 'Verknüpfen & in Text einfügen'}
|
2026-05-08 12:00:02 +02:00
|
|
|
</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>
|
|
|
|
|
)
|
|
|
|
|
}
|