shinkan-jinkendo/frontend/src/components/ExerciseInlineFileMediaModal.jsx

552 lines
25 KiB
React
Raw Normal View History

/**
* Modal: Medium aus Archiv verknüpfen oder neue Datei hochladen, dann Inline-Platzhalter §11 einfügen.
*/
import React, { useEffect, useState, useCallback, useMemo } from 'react'
import api from '../utils/api'
import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
import {
INLINE_MEDIA_SIZES,
DEFAULT_INLINE_MEDIA_SIZE,
sanitizeInlineMediaSize,
} from '../constants/inlineExerciseMedia'
import { sanitizeInlineMediaCaption } from '../utils/inlineMediaCaption'
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>
}
/** 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'
}
const DECL_INIT = {
copyright_notice: '',
rights_holder_confirmed: false,
contains_identifiable_persons: null,
person_consent_confirmed: false,
person_consent_context: '',
contains_minors: null,
parental_consent_confirmed: false,
parental_consent_context: '',
contains_music: null,
music_rights_confirmed: false,
music_rights_context: '',
contains_third_party_content: null,
third_party_rights_confirmed: false,
third_party_rights_context: '',
}
function validateDecl(decl) {
if (!decl.rights_holder_confirmed)
return 'Bitte bestätigen, dass du die erforderlichen Rechte an diesem Medium besitzt.'
if (decl.contains_identifiable_persons === null)
return 'Bitte angeben, ob erkennbare Personen abgebildet sind.'
if (decl.contains_identifiable_persons && !decl.person_consent_confirmed)
return 'Bitte bestätigen, dass die Einwilligungen aller erkennbaren Personen vorliegen.'
if (decl.contains_minors === null)
return 'Bitte angeben, ob Minderjährige abgebildet sind.'
if (decl.contains_minors && !decl.parental_consent_confirmed)
return 'Bitte bestätigen, dass die Einwilligungen der Sorgeberechtigten vorliegen.'
if (decl.contains_music === null)
return 'Bitte angeben, ob das Medium Musik enthält.'
if (decl.contains_music && !decl.music_rights_confirmed)
return 'Bitte bestätigen, dass die erforderlichen Musikrechte vorliegen.'
if (decl.contains_third_party_content === null)
return 'Bitte angeben, ob fremde geschützte Inhalte enthalten sind.'
if (decl.contains_third_party_content && !decl.third_party_rights_confirmed)
return 'Bitte bestätigen, dass die Rechte an allen enthaltenen Fremdmaterialien vorliegen.'
return ''
}
/**
* @param {{
* open: boolean,
* onClose: () => void,
* exerciseId: number,
* linkedExerciseMedia?: object[],
* onMediaListChanged: () => Promise<void>,
* onInserted: (exerciseMediaId: number, displaySize: string, caption?: string) => void,
* }} props
*/
export default function ExerciseInlineFileMediaModal({
open,
onClose,
exerciseId,
linkedExerciseMedia = [],
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 [uploadInputKey, setUploadInputKey] = useState(0)
const [decl, setDecl] = useState({ ...DECL_INIT })
const [declErr, setDeclErr] = useState('')
const setDeclField = (key, val) => setDecl((d) => ({ ...d, [key]: val }))
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])
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('')
setUploadInputKey((k) => k + 1)
setDisplaySize(DEFAULT_INLINE_MEDIA_SIZE)
setErr(null)
setDecl({ ...DECL_INIT })
setDeclErr('')
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)
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
}
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,
sanitizeInlineMediaCaption(assetMeta?.original_filename || ''),
)
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 validErr = validateDecl(decl)
if (validErr) {
setDeclErr(validErr)
return
}
setDeclErr('')
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')
for (const [k, v] of Object.entries(decl)) {
fd.append(k, String(v))
}
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()
const cap = sanitizeInlineMediaCaption(
uploadTitle.trim() || uploadFile.name || '',
)
onInserted(Number(mid), size, cap)
setUploadFile(null)
setUploadTitle('')
setUploadInputKey((k) => k + 1)
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)
}
}
const selectedLinked = selectedAssetId != null && assetToExerciseMedia.has(Number(selectedAssetId))
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' }}>
{err && !loading ? (
<p style={{ color: 'var(--danger)', marginTop: '0', marginBottom: '12px', fontSize: '0.9rem' }}>{err}</p>
) : null}
{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}`
const linked = assetToExerciseMedia.has(Number(id))
return (
<button
key={id}
type="button"
className={`rte-inline-asset-tile${selected ? ' rte-inline-asset-tile--selected' : ''}`}
onClick={() => setSelectedAssetId(id)}
disabled={busy}
>
<div className="rte-inline-asset-tile__thumb" aria-hidden>
<RtePickerAssetThumb asset={it} />
</div>
{linked ? (
<span className="rte-inline-asset-tile__badge">Bereits verknüpft</span>
) : null}
<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>
<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>
<label className="form-label" style={{ marginTop: '12px' }}>
Titel (optional)
</label>
<input
className="form-input"
value={uploadTitle}
onChange={(e) => setUploadTitle(e.target.value)}
disabled={busy}
/>
{/* P-06 Rechte-Erklärung */}
<div style={{ marginTop: '18px', paddingTop: '14px', borderTop: '1px solid var(--border)' }}>
<p style={{ fontSize: '0.82rem', fontWeight: 600, marginBottom: '10px', color: 'var(--text2)' }}>
Rechte-Erklärung <span style={{ fontWeight: 400, color: 'var(--text3)' }}>(VORLÄUFIG – p06-v1-conservative)</span>
</p>
<div style={{ marginBottom: '10px' }}>
<label htmlFor="up-cr" style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, marginBottom: '3px' }}>Copyright-Angabe (optional)</label>
<input id="up-cr" type="text" className="form-input"
placeholder="z.B. © 2026 Lars Stommer, CC BY-NC"
value={decl.copyright_notice}
onChange={(e) => setDeclField('copyright_notice', e.target.value)}
disabled={busy} style={{ fontSize: '0.85rem' }} />
</div>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '10px' }}>
<input type="checkbox" id="up-rhc" checked={decl.rights_holder_confirmed}
onChange={(e) => setDeclField('rights_holder_confirmed', e.target.checked)}
disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} />
<label htmlFor="up-rhc" style={{ fontSize: '0.85rem' }}>
Ich bestätige, dass ich die erforderlichen Rechte an diesem Medium besitze oder zur Veröffentlichung berechtigt bin. *
</label>
</div>
<fieldset style={{ border: 'none', padding: 0, marginBottom: '10px' }}>
<legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Erkennbare Personen abgebildet? *</legend>
<div style={{ display: 'flex', gap: '14px', marginBottom: '4px' }}>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="up-cip" checked={decl.contains_identifiable_persons === true} onChange={() => setDeclField('contains_identifiable_persons', true)} disabled={busy} /> Ja</label>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="up-cip" checked={decl.contains_identifiable_persons === false} onChange={() => setDeclField('contains_identifiable_persons', false)} disabled={busy} /> Nein</label>
</div>
{decl.contains_identifiable_persons === true && (
<div style={{ paddingLeft: 2 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '4px' }}>
<input type="checkbox" id="up-pcc" checked={decl.person_consent_confirmed} onChange={(e) => setDeclField('person_consent_confirmed', e.target.checked)} disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} />
<label htmlFor="up-pcc" style={{ fontSize: '0.85rem' }}>Einwilligungen aller erkennbaren Personen liegen vor. *</label>
</div>
<label style={{ display: 'block', fontSize: '0.8rem', color: 'var(--text2)', marginBottom: '2px' }}>Einwilligungskontext (optional)</label>
<textarea className="form-input" rows={2} placeholder="z.B. Schriftliche Einwilligung vom 2026-05-01" value={decl.person_consent_context} onChange={(e) => setDeclField('person_consent_context', e.target.value)} disabled={busy} style={{ fontSize: '0.82rem', resize: 'vertical' }} />
</div>
)}
</fieldset>
<fieldset style={{ border: 'none', padding: 0, marginBottom: '10px' }}>
<legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Minderjährige abgebildet? *</legend>
<div style={{ display: 'flex', gap: '14px', marginBottom: '4px' }}>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="up-cm" checked={decl.contains_minors === true} onChange={() => setDeclField('contains_minors', true)} disabled={busy} /> Ja</label>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="up-cm" checked={decl.contains_minors === false} onChange={() => setDeclField('contains_minors', false)} disabled={busy} /> Nein</label>
</div>
{decl.contains_minors === true && (
<div style={{ paddingLeft: 2 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '4px' }}>
<input type="checkbox" id="up-pcc2" checked={decl.parental_consent_confirmed} onChange={(e) => setDeclField('parental_consent_confirmed', e.target.checked)} disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} />
<label htmlFor="up-pcc2" style={{ fontSize: '0.85rem' }}>Einwilligungen der Sorgeberechtigten liegen vor. *</label>
</div>
<label style={{ display: 'block', fontSize: '0.8rem', color: 'var(--text2)', marginBottom: '2px' }}>Einwilligungskontext (optional)</label>
<textarea className="form-input" rows={2} placeholder="z.B. Elterliche Einwilligung per E-Mail vom 2026-04-15" value={decl.parental_consent_context} onChange={(e) => setDeclField('parental_consent_context', e.target.value)} disabled={busy} style={{ fontSize: '0.82rem', resize: 'vertical' }} />
</div>
)}
</fieldset>
<fieldset style={{ border: 'none', padding: 0, marginBottom: '10px' }}>
<legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Musik enthalten? *</legend>
<div style={{ display: 'flex', gap: '14px', marginBottom: '4px' }}>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="up-cmu" checked={decl.contains_music === true} onChange={() => setDeclField('contains_music', true)} disabled={busy} /> Ja</label>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="up-cmu" checked={decl.contains_music === false} onChange={() => setDeclField('contains_music', false)} disabled={busy} /> Nein</label>
</div>
{decl.contains_music === true && (
<div style={{ paddingLeft: 2 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '4px' }}>
<input type="checkbox" id="up-mrc" checked={decl.music_rights_confirmed} onChange={(e) => setDeclField('music_rights_confirmed', e.target.checked)} disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} />
<label htmlFor="up-mrc" style={{ fontSize: '0.85rem' }}>Musikrechte (GEMA / Lizenz) liegen vor. *</label>
</div>
<label style={{ display: 'block', fontSize: '0.8rem', color: 'var(--text2)', marginBottom: '2px' }}>Lizenz / GEMA-Kontext (optional)</label>
<textarea className="form-input" rows={2} placeholder="z.B. CC BY 4.0 oder GEMA-Freimeldung Nr. …" value={decl.music_rights_context} onChange={(e) => setDeclField('music_rights_context', e.target.value)} disabled={busy} style={{ fontSize: '0.82rem', resize: 'vertical' }} />
</div>
)}
</fieldset>
<fieldset style={{ border: 'none', padding: 0, marginBottom: '4px' }}>
<legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Fremde geschützte Inhalte (Logos, Grafiken, Fotos Dritter)? *</legend>
<div style={{ display: 'flex', gap: '14px', marginBottom: '4px' }}>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="up-ctpc" checked={decl.contains_third_party_content === true} onChange={() => setDeclField('contains_third_party_content', true)} disabled={busy} /> Ja</label>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="up-ctpc" checked={decl.contains_third_party_content === false} onChange={() => setDeclField('contains_third_party_content', false)} disabled={busy} /> Nein</label>
</div>
{decl.contains_third_party_content === true && (
<div style={{ paddingLeft: 2 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '4px' }}>
<input type="checkbox" id="up-tprc" checked={decl.third_party_rights_confirmed} onChange={(e) => setDeclField('third_party_rights_confirmed', e.target.checked)} disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} />
<label htmlFor="up-tprc" style={{ fontSize: '0.85rem' }}>Rechte an allen enthaltenen Fremdmaterialien liegen vor. *</label>
</div>
<label style={{ display: 'block', fontSize: '0.8rem', color: 'var(--text2)', marginBottom: '2px' }}>Rechtsgrundlage (optional)</label>
<textarea className="form-input" rows={2} placeholder="z.B. Verbandslogo mit Genehmigung vom 2026-03-10" value={decl.third_party_rights_context} onChange={(e) => setDeclField('third_party_rights_context', e.target.value)} disabled={busy} style={{ fontSize: '0.82rem', resize: 'vertical' }} />
</div>
)}
</fieldset>
{declErr && (
<p style={{ color: 'var(--danger)', fontSize: '0.85rem', marginTop: '8px' }}>{declErr}</p>
)}
</div>
</>
)}
</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 ? 'Bitte warten…' : selectedLinked ? 'In Text einfügen (bereits verknüpft)' : '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>
)
}