shinkan-jinkendo/frontend/src/components/ExerciseInlineFileMediaModal.jsx
Lars 4bc24b4caf
Some checks failed
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 33s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 10s
Test Suite / playwright-tests (push) Failing after 1m2s
feat(p06): Copyright-Feld und Einwilligungskontext in Rechte-Erklaerung
Migration 049: 4 optionale TEXT-Spalten in media_asset_rights_declarations
(person_consent_context, parental_consent_context, music_rights_context,
third_party_rights_context) fuer Freitext zum Einwilligungskontext.

Backend:
- media_rights.py: write_rights_declaration speichert 4 Kontextfelder
- media_assets.py: copyright_notice + 4 Kontextfelder in Bulk-Upload,
  RightsDeclarationBody, MediaAssetPatch, MediaBulkPatchBody
- exercises.py: copyright_notice + 4 Kontextfelder in upload_exercise_media,
  wird in INSERT gespeichert

Frontend (alle 3 Formulare):
- RightsDeclarationDialog: Copyright-Eingabefeld (immer sichtbar) +
  Freitext-Textarea bei jeder Ja-Antwort (Personen, Minderjaehrige,
  Musik, Fremdinhalte)
- ExerciseInlineFileMediaModal: gleiche Felder inline im Upload-Tab
- ExerciseInlineEmbedModal: gleiche Felder inline
- api.js: copyright_notice + 4 Kontextfelder in bulkUploadMediaAssets

version: 0.8.77
module: media_rights 1.1.0, media_assets 1.14.0, exercises 2.21.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 09:06:47 +02:00

552 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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>
)
}