feat(exercises): enhance inline media functionality and update styles
All checks were successful
Deploy Development / deploy (push) Successful in 33s
Test Suite / pytest-backend (push) Successful in 24s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 27s
All checks were successful
Deploy Development / deploy (push) Successful in 33s
Test Suite / pytest-backend (push) Successful in 24s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 27s
- Updated inline media markup to include a new data attribute for media size. - Enhanced the Rich Text Editor to support media size selection when inserting inline media. - Improved CSS styles for inline media display, accommodating different sizes (small, medium, full). - Bumped version to 0.8.62 and updated changelog to reflect these changes.
This commit is contained in:
parent
979e328cef
commit
311a106d93
|
|
@ -35,7 +35,10 @@ def normalize_inline_exercise_media_markup(html: Optional[str]) -> Optional[str]
|
||||||
|
|
||||||
def _repl(match: re.Match) -> str:
|
def _repl(match: re.Match) -> str:
|
||||||
mid = int(match.group(1))
|
mid = int(match.group(1))
|
||||||
return f'<span data-shinkan-exercise-media="{mid}" class="shinkan-inline-media"></span>'
|
return (
|
||||||
|
f'<span data-shinkan-exercise-media="{mid}" data-shinkan-exercise-media-size="medium" '
|
||||||
|
f'class="shinkan-inline-media"></span>'
|
||||||
|
)
|
||||||
|
|
||||||
return _BRACE_PATTERN.sub(_repl, html)
|
return _BRACE_PATTERN.sub(_repl, html)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ def test_normalize_curly_to_span() -> None:
|
||||||
s = '<p>Vor {{exerciseMedia: 42 }} nach</p>'
|
s = '<p>Vor {{exerciseMedia: 42 }} nach</p>'
|
||||||
out = normalize_inline_exercise_media_markup(s)
|
out = normalize_inline_exercise_media_markup(s)
|
||||||
assert 'data-shinkan-exercise-media="42"' in out
|
assert 'data-shinkan-exercise-media="42"' in out
|
||||||
|
assert 'data-shinkan-exercise-media-size="medium"' in out
|
||||||
assert "{{" not in out
|
assert "{{" not in out
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.61"
|
APP_VERSION = "0.8.62"
|
||||||
BUILD_DATE = "2026-05-08"
|
BUILD_DATE = "2026-05-08"
|
||||||
DB_SCHEMA_VERSION = "20260508049"
|
DB_SCHEMA_VERSION = "20260508049"
|
||||||
|
|
||||||
|
|
@ -29,6 +29,13 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "0.8.62",
|
||||||
|
"date": "2026-05-08",
|
||||||
|
"changes": [
|
||||||
|
"RTE Inline-Medien: Modals Mediathek+Hochladen + „Embed im Text“; Darstellungsgröße small|medium|full (data-shinkan-exercise-media-size); Lesemodus begrenzt Bild/Video-Breite",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.8.61",
|
"version": "0.8.61",
|
||||||
"date": "2026-05-08",
|
"date": "2026-05-08",
|
||||||
|
|
|
||||||
|
|
@ -3912,10 +3912,10 @@ a.analysis-split__nav-item {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
.rich-text-editor span.shinkan-inline-media::before {
|
.rich-text-editor span.shinkan-inline-media::before {
|
||||||
content: '📎 #' attr(data-shinkan-exercise-media);
|
content: '📎 #' attr(data-shinkan-exercise-media) ' · ' attr(data-shinkan-exercise-media-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Listen im Editor (nicht nur in .rich-text-content) – sonst „unsichtbare“ Bullets */
|
/* Listen im Editor */
|
||||||
.rich-text-editor ul,
|
.rich-text-editor ul,
|
||||||
.rich-text-editor ol {
|
.rich-text-editor ol {
|
||||||
margin: 0.35rem 0;
|
margin: 0.35rem 0;
|
||||||
|
|
@ -3941,6 +3941,63 @@ a.analysis-split__nav-item {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rich-text-content .shinkan-inline-media-wrap--sm {
|
||||||
|
max-width: min(280px, 92vw);
|
||||||
|
}
|
||||||
|
.rich-text-content .shinkan-inline-media-wrap--md {
|
||||||
|
max-width: min(560px, 92vw);
|
||||||
|
}
|
||||||
|
.rich-text-content .shinkan-inline-media-wrap--full {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary.rte-tab--active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px rgba(29, 158, 117, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rte-inline-asset-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.rte-inline-asset-tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
text-align: left;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.rte-inline-asset-tile:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.rte-inline-asset-tile--selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px rgba(29, 158, 117, 0.2);
|
||||||
|
}
|
||||||
|
.rte-inline-asset-tile__meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text3);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.rte-inline-asset-tile__name {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--text1);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.rich-text-content {
|
.rich-text-content {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{m.description && <p style={{ color: 'var(--text2)', fontSize: '0.82rem', marginTop: '4px' }}>{m.description}</p>}
|
{m.description && <p style={{ color: 'var(--text2)', fontSize: '0.82rem', marginTop: '4px' }}>{m.description}</p>}
|
||||||
<ExerciseMediaEmbed media={m} exerciseId={resolvedId} />
|
<ExerciseMediaEmbed media={m} exerciseId={resolvedId} layoutSize="full" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
128
frontend/src/components/ExerciseInlineEmbedModal.jsx
Normal file
128
frontend/src/components/ExerciseInlineEmbedModal.jsx
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
/**
|
||||||
|
* Modal: Embed-URL als exercise_media anlegen und §11-Platzhalter einfügen.
|
||||||
|
*/
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import api from '../utils/api'
|
||||||
|
import {
|
||||||
|
INLINE_MEDIA_SIZES,
|
||||||
|
DEFAULT_INLINE_MEDIA_SIZE,
|
||||||
|
sanitizeInlineMediaSize,
|
||||||
|
} from '../constants/inlineExerciseMedia'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{
|
||||||
|
* open: boolean,
|
||||||
|
* onClose: () => void,
|
||||||
|
* exerciseId: number,
|
||||||
|
* onMediaListChanged: () => Promise<void>,
|
||||||
|
* onInserted: (exerciseMediaId: number, displaySize: string) => void,
|
||||||
|
* }} props
|
||||||
|
*/
|
||||||
|
export default function ExerciseInlineEmbedModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
exerciseId,
|
||||||
|
onMediaListChanged,
|
||||||
|
onInserted,
|
||||||
|
}) {
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [displaySize, setDisplaySize] = useState(DEFAULT_INLINE_MEDIA_SIZE)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setUrl('')
|
||||||
|
setTitle('')
|
||||||
|
setDisplaySize(DEFAULT_INLINE_MEDIA_SIZE)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
const u = url.trim()
|
||||||
|
if (!u) {
|
||||||
|
alert('Bitte eine Embed-URL eingeben (https://…).')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const size = sanitizeInlineMediaSize(displaySize)
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('embed_url', u)
|
||||||
|
fd.append('media_type', 'video')
|
||||||
|
fd.append('title', title.trim())
|
||||||
|
fd.append('description', '')
|
||||||
|
fd.append('context', 'ablauf')
|
||||||
|
fd.append('is_primary', 'false')
|
||||||
|
setBusy(true)
|
||||||
|
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)
|
||||||
|
onClose()
|
||||||
|
} catch (e) {
|
||||||
|
alert(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"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="rte-inline-embed-title"
|
||||||
|
style={{ maxWidth: '480px', width: '100%' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="admin-modal-sheet__header">
|
||||||
|
<h3 id="rte-inline-embed-title" className="admin-modal-sheet__title">
|
||||||
|
Embed 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: '14px 16px' }}>
|
||||||
|
<label className="form-label">Embed-URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="https://…"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<label className="form-label" style={{ marginTop: '12px' }}>
|
||||||
|
Titel (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<label className="form-label" style={{ marginTop: '12px' }}>
|
||||||
|
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>
|
||||||
|
<button type="button" className="btn btn-primary btn-full" style={{ marginTop: '16px' }} disabled={busy} onClick={submit}>
|
||||||
|
{busy ? 'Speichern…' : 'Hinzufügen & in Text einfügen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
308
frontend/src/components/ExerciseInlineFileMediaModal.jsx
Normal file
308
frontend/src/components/ExerciseInlineFileMediaModal.jsx
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,36 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
|
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
|
||||||
|
import { sanitizeInlineMediaSize } from '../constants/inlineExerciseMedia'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ein ausgeliefertes exercise_media für Übungslisten (Liste + Inline gleiche Darstellung).
|
* Ein ausgeliefertes exercise_media für Übungslisten (Liste + Inline gleiche Darstellung).
|
||||||
* @param {{ media: object, exerciseId: number }} props
|
* @param {{ media: object, exerciseId: number, layoutSize?: string }} props
|
||||||
*/
|
*/
|
||||||
export default function ExerciseMediaEmbed({ exerciseId, media }) {
|
export default function ExerciseMediaEmbed({ exerciseId, media, layoutSize = 'medium' }) {
|
||||||
|
const sz = sanitizeInlineMediaSize(layoutSize)
|
||||||
|
const box =
|
||||||
|
sz === 'small'
|
||||||
|
? { maxWidth: 'min(280px, 33vw)', marginTop: '0.5rem' }
|
||||||
|
: sz === 'full'
|
||||||
|
? { maxWidth: '100%', marginTop: '0.5rem' }
|
||||||
|
: { maxWidth: 'min(560px, 85vw)', marginTop: '0.5rem' }
|
||||||
|
|
||||||
if (!media || exerciseId == null) return null
|
if (!media || exerciseId == null) return null
|
||||||
if (media.embed_url) {
|
if (media.embed_url) {
|
||||||
return (
|
return (
|
||||||
<div style={{ marginTop: '0.5rem' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
...box,
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
overflowWrap: 'anywhere',
|
||||||
|
fontSize: sz === 'small' ? '0.88rem' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<a href={media.embed_url} target="_blank" rel="noreferrer">
|
<a href={media.embed_url} target="_blank" rel="noreferrer">
|
||||||
{media.embed_url}
|
{media.title?.trim() || media.embed_url}
|
||||||
</a>
|
</a>
|
||||||
{media.embed_platform && (
|
{media.embed_platform && (
|
||||||
<span style={{ color: 'var(--text2)', marginLeft: '0.5rem', fontSize: '0.8rem' }}>
|
<span style={{ color: 'var(--text2)', marginLeft: '0.35rem', fontSize: '0.82rem', display: 'inline' }}>
|
||||||
({media.embed_platform})
|
({media.embed_platform})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -25,19 +41,31 @@ export default function ExerciseMediaEmbed({ exerciseId, media }) {
|
||||||
if (!src) return null
|
if (!src) return null
|
||||||
if (media.media_type === 'image' || (media.mime_type && media.mime_type.startsWith('image/'))) {
|
if (media.media_type === 'image' || (media.mime_type && media.mime_type.startsWith('image/'))) {
|
||||||
return (
|
return (
|
||||||
<img
|
<div style={box}>
|
||||||
src={src}
|
<img
|
||||||
alt={media.title || media.original_filename || ''}
|
src={src}
|
||||||
style={{ maxWidth: '100%', borderRadius: '8px', marginTop: '0.5rem' }}
|
alt={media.title || media.original_filename || ''}
|
||||||
/>
|
style={{ width: '100%', maxWidth: '100%', height: 'auto', borderRadius: '8px', display: 'block' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (media.media_type === 'video' || (media.mime_type && media.mime_type.startsWith('video/'))) {
|
if (media.media_type === 'video' || (media.mime_type && media.mime_type.startsWith('video/'))) {
|
||||||
return <video src={src} controls style={{ width: '100%', marginTop: '0.5rem', borderRadius: '8px' }} />
|
return (
|
||||||
|
<div style={box}>
|
||||||
|
<video
|
||||||
|
src={src}
|
||||||
|
controls
|
||||||
|
style={{ width: '100%', maxWidth: '100%', borderRadius: '8px', verticalAlign: 'top' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<a href={src} target="_blank" rel="noreferrer" style={{ display: 'inline-block', marginTop: '0.5rem' }}>
|
<div style={box}>
|
||||||
{media.title || media.original_filename || 'Datei öffnen'}
|
<a href={src} target="_blank" rel="noreferrer" style={{ display: 'inline-block', marginTop: '0.25rem' }}>
|
||||||
</a>
|
{media.title || media.original_filename || 'Datei öffnen'}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,15 +44,23 @@ function domToReactNodes(node, exerciseId, mediaById, path) {
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
const rawSize = (el.getAttribute('data-shinkan-exercise-media-size') || 'medium').toLowerCase().trim()
|
||||||
|
const layoutSize = rawSize === 'small' || rawSize === 'full' ? rawSize : 'medium'
|
||||||
|
const wrapClass =
|
||||||
|
layoutSize === 'small'
|
||||||
|
? 'shinkan-inline-media-wrap shinkan-inline-media-wrap--sm'
|
||||||
|
: layoutSize === 'full'
|
||||||
|
? 'shinkan-inline-media-wrap shinkan-inline-media-wrap--full'
|
||||||
|
: 'shinkan-inline-media-wrap shinkan-inline-media-wrap--md'
|
||||||
const lc = String(media.asset_lifecycle_state || 'active').toLowerCase()
|
const lc = String(media.asset_lifecycle_state || 'active').toLowerCase()
|
||||||
return (
|
return (
|
||||||
<span key={key} className="shinkan-inline-media-wrap" style={{ display: 'inline-block', verticalAlign: 'top', maxWidth: '100%' }}>
|
<span key={key} className={wrapClass} style={{ display: 'inline-block', verticalAlign: 'top' }}>
|
||||||
{lc === 'trash_soft' && (
|
{lc === 'trash_soft' && (
|
||||||
<span style={{ fontSize: '0.75rem', color: 'var(--danger)', display: 'block', marginBottom: '4px' }}>
|
<span style={{ fontSize: '0.75rem', color: 'var(--danger)', display: 'block', marginBottom: '4px' }}>
|
||||||
Dieses Medium ist im Papierkorb.
|
Dieses Medium ist im Papierkorb.
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<ExerciseMediaEmbed exerciseId={exerciseId} media={media} />
|
<ExerciseMediaEmbed exerciseId={exerciseId} media={media} layoutSize={layoutSize} />
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import React, { useRef, useEffect, useState, useCallback } from 'react'
|
import React, { useRef, useEffect, useState, useCallback } from 'react'
|
||||||
|
import ExerciseInlineFileMediaModal from './ExerciseInlineFileMediaModal'
|
||||||
|
import ExerciseInlineEmbedModal from './ExerciseInlineEmbedModal'
|
||||||
|
|
||||||
function exec(cmd, value = null) {
|
function exec(cmd, value = null) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -45,10 +47,17 @@ function normalText() {
|
||||||
formatBlock('p')
|
formatBlock('p')
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertExerciseMediaPlaceholder(editorEl, mediaId) {
|
function buildInlineExerciseMediaHtml(mediaId, displaySize = 'medium') {
|
||||||
if (!editorEl || mediaId == null) return false
|
|
||||||
const sid = parseInt(String(mediaId), 10)
|
const sid = parseInt(String(mediaId), 10)
|
||||||
if (!Number.isFinite(sid) || sid < 1) return false
|
if (!Number.isFinite(sid) || sid < 1) return null
|
||||||
|
const sz = ['small', 'medium', 'full'].includes(displaySize) ? displaySize : 'medium'
|
||||||
|
return `<span data-shinkan-exercise-media="${sid}" data-shinkan-exercise-media-size="${sz}" class="shinkan-inline-media">\u2060</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertExerciseMediaPlaceholder(editorEl, mediaId, displaySize = 'medium') {
|
||||||
|
if (!editorEl || mediaId == null) return false
|
||||||
|
const html = buildInlineExerciseMediaHtml(mediaId, displaySize)
|
||||||
|
if (!html) return false
|
||||||
|
|
||||||
editorEl.focus()
|
editorEl.focus()
|
||||||
const sel = window.getSelection()
|
const sel = window.getSelection()
|
||||||
|
|
@ -75,7 +84,6 @@ function insertExerciseMediaPlaceholder(editorEl, mediaId) {
|
||||||
sel.addRange(anchor)
|
sel.addRange(anchor)
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = `<span data-shinkan-exercise-media="${sid}" class="shinkan-inline-media">\u2060</span>`
|
|
||||||
let inserted = false
|
let inserted = false
|
||||||
try {
|
try {
|
||||||
inserted = document.execCommand('insertHTML', false, html)
|
inserted = document.execCommand('insertHTML', false, html)
|
||||||
|
|
@ -98,10 +106,10 @@ function insertExerciseMediaPlaceholder(editorEl, mediaId) {
|
||||||
sel.addRange(range)
|
sel.addRange(range)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const span = document.createElement('span')
|
const tpl = document.createElement('template')
|
||||||
span.setAttribute('data-shinkan-exercise-media', String(sid))
|
tpl.innerHTML = html
|
||||||
span.className = 'shinkan-inline-media'
|
const span = tpl.content.firstChild
|
||||||
span.appendChild(document.createTextNode('\u2060'))
|
if (!span) return false
|
||||||
range.deleteContents()
|
range.deleteContents()
|
||||||
range.insertNode(span)
|
range.insertNode(span)
|
||||||
range.setStartAfter(span)
|
range.setStartAfter(span)
|
||||||
|
|
@ -115,18 +123,22 @@ function insertExerciseMediaPlaceholder(editorEl, mediaId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Leichter WYSIWYG (contenteditable). Wert kommt von außen zuverlässig ins DOM (Edit-Modus).
|
* Leichter WYSIWYG (contenteditable).
|
||||||
* @param {{ id: number, label: string }[]} [insertExerciseMediaSlots] — §11 Verweise auf exercise_media.id
|
* @param {{ inlineExerciseId?: number|null, onExerciseMediaListChanged?: () => Promise<void> }} [extra]
|
||||||
*/
|
*/
|
||||||
export default function RichTextEditor({
|
export default function RichTextEditor({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
minHeight = '140px',
|
minHeight = '140px',
|
||||||
insertExerciseMediaSlots,
|
inlineExerciseId = null,
|
||||||
|
onExerciseMediaListChanged,
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef(null)
|
const ref = useRef(null)
|
||||||
|
const pendingRangeRef = useRef(null)
|
||||||
const [focused, setFocused] = useState(false)
|
const [focused, setFocused] = useState(false)
|
||||||
|
const [fileModalOpen, setFileModalOpen] = useState(false)
|
||||||
|
const [embedModalOpen, setEmbedModalOpen] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = ref.current
|
const el = ref.current
|
||||||
|
|
@ -142,6 +154,41 @@ export default function RichTextEditor({
|
||||||
onChange(ref.current.innerHTML)
|
onChange(ref.current.innerHTML)
|
||||||
}, [onChange])
|
}, [onChange])
|
||||||
|
|
||||||
|
const refreshExerciseMedia = useCallback(async () => {
|
||||||
|
if (onExerciseMediaListChanged) {
|
||||||
|
await onExerciseMediaListChanged()
|
||||||
|
}
|
||||||
|
}, [onExerciseMediaListChanged])
|
||||||
|
|
||||||
|
const stashRangeAndOpen = useCallback((openFn) => (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const el = ref.current
|
||||||
|
pendingRangeRef.current = el ? saveSelectionInside(el) : null
|
||||||
|
openFn()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const finalizeInsertFromModal = useCallback(
|
||||||
|
(mediaId, displaySize) => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
const shell = ref.current
|
||||||
|
if (!shell) return
|
||||||
|
shell.focus()
|
||||||
|
restoreSelection(pendingRangeRef.current)
|
||||||
|
const ok = insertExerciseMediaPlaceholder(shell, mediaId, displaySize)
|
||||||
|
if (!ok) {
|
||||||
|
alert(
|
||||||
|
'Einfügen ist fehlgeschlagen — bitte Cursor ins Textfeld setzen und den Schalter erneut verwenden.',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sync()
|
||||||
|
shell.focus()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[sync],
|
||||||
|
)
|
||||||
|
|
||||||
const run = (fn) => (e) => {
|
const run = (fn) => (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
@ -174,57 +221,7 @@ export default function RichTextEditor({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const showMediaPick = Array.isArray(insertExerciseMediaSlots) && insertExerciseMediaSlots.length > 0
|
const showInlineToolbar = inlineExerciseId != null && Number(inlineExerciseId) > 0
|
||||||
|
|
||||||
const onInsertExerciseMediaClick = (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
const el = ref.current
|
|
||||||
const slots = insertExerciseMediaSlots
|
|
||||||
if (!el || !slots?.length) return
|
|
||||||
let choice = ''
|
|
||||||
if (slots.length === 1) {
|
|
||||||
choice = String(slots[0].id)
|
|
||||||
} else {
|
|
||||||
choice =
|
|
||||||
window.prompt(
|
|
||||||
`Medium-ID eingeben oder aus Liste:\n${slots
|
|
||||||
.slice(0, 30)
|
|
||||||
.map((s) => `${s.id}: ${s.label}`)
|
|
||||||
.join('\n')}`,
|
|
||||||
String(slots[0].id),
|
|
||||||
) ?? ''
|
|
||||||
}
|
|
||||||
const idParsed = parseInt(String(choice).trim(), 10)
|
|
||||||
if (!Number.isFinite(idParsed)) {
|
|
||||||
if (slots.length > 1) {
|
|
||||||
alert('Keine gültige Medium-ID angegeben.')
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!slots.some((s) => Number(s.id) === idParsed)) {
|
|
||||||
alert('Diese ID ist nicht in der Medienliste dieser Übung.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const savedRange = saveSelectionInside(el)
|
|
||||||
|
|
||||||
queueMicrotask(() => {
|
|
||||||
const shell = ref.current
|
|
||||||
if (!shell) return
|
|
||||||
shell.focus()
|
|
||||||
restoreSelection(savedRange)
|
|
||||||
const ok = insertExerciseMediaPlaceholder(shell, idParsed)
|
|
||||||
if (!ok) {
|
|
||||||
alert(
|
|
||||||
'Einfügen ist fehlgeschlagen — bitte einmal ins Textfeld klicken (Cursor setzen), dann „Bild/Video im Text“ erneut.',
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sync()
|
|
||||||
shell.focus()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rich-text-editor-wrap">
|
<div className="rich-text-editor-wrap">
|
||||||
|
|
@ -261,15 +258,25 @@ export default function RichTextEditor({
|
||||||
<button type="button" className="rte-btn" title="Link einfügen" onMouseDown={onLink}>
|
<button type="button" className="rte-btn" title="Link einfügen" onMouseDown={onLink}>
|
||||||
Link
|
Link
|
||||||
</button>
|
</button>
|
||||||
{showMediaPick ? (
|
{showInlineToolbar ? (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<button
|
||||||
className="rte-btn"
|
type="button"
|
||||||
title="Übungs-Medium inline einfügen (§11)"
|
className="rte-btn"
|
||||||
onMouseDown={onInsertExerciseMediaClick}
|
title="Datei aus Mediathek oder neu hochladen, in den Text einfügen"
|
||||||
>
|
onMouseDown={stashRangeAndOpen(() => setFileModalOpen(true))}
|
||||||
Bild/Video im Text
|
>
|
||||||
</button>
|
Medien im Text
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rte-btn"
|
||||||
|
title="Embed-URL hinzufügen und im Text einfügen"
|
||||||
|
onMouseDown={stashRangeAndOpen(() => setEmbedModalOpen(true))}
|
||||||
|
>
|
||||||
|
Embed im Text
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -297,6 +304,25 @@ export default function RichTextEditor({
|
||||||
}}
|
}}
|
||||||
onInput={sync}
|
onInput={sync}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{showInlineToolbar ? (
|
||||||
|
<ExerciseInlineFileMediaModal
|
||||||
|
open={fileModalOpen}
|
||||||
|
onClose={() => setFileModalOpen(false)}
|
||||||
|
exerciseId={Number(inlineExerciseId)}
|
||||||
|
onMediaListChanged={refreshExerciseMedia}
|
||||||
|
onInserted={(mid, sz) => finalizeInsertFromModal(mid, sz)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{showInlineToolbar ? (
|
||||||
|
<ExerciseInlineEmbedModal
|
||||||
|
open={embedModalOpen}
|
||||||
|
onClose={() => setEmbedModalOpen(false)}
|
||||||
|
exerciseId={Number(inlineExerciseId)}
|
||||||
|
onMediaListChanged={refreshExerciseMedia}
|
||||||
|
onInserted={(mid, sz) => finalizeInsertFromModal(mid, sz)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
frontend/src/constants/inlineExerciseMedia.js
Normal file
14
frontend/src/constants/inlineExerciseMedia.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
/** Inline-Medium im Fließtext §11 — Darstellung (CSS + data-shinkan-exercise-media-size). */
|
||||||
|
export const INLINE_MEDIA_SIZES = [
|
||||||
|
{ value: 'small', label: 'Klein (~33 %)' },
|
||||||
|
{ value: 'medium', label: 'Mittel (~66 %)', default: true },
|
||||||
|
{ value: 'full', label: 'Volle Breite' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DEFAULT_INLINE_MEDIA_SIZE = 'medium'
|
||||||
|
|
||||||
|
export function sanitizeInlineMediaSize(v) {
|
||||||
|
const s = String(v || '').toLowerCase().trim()
|
||||||
|
if (s === 'small' || s === 'medium' || s === 'full') return s
|
||||||
|
return DEFAULT_INLINE_MEDIA_SIZE
|
||||||
|
}
|
||||||
|
|
@ -180,7 +180,7 @@ function ExerciseDetailPage() {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{m.description && <p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{m.description}</p>}
|
{m.description && <p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{m.description}</p>}
|
||||||
<ExerciseMediaEmbed media={m} exerciseId={exercise.id} />
|
<ExerciseMediaEmbed media={m} exerciseId={exercise.id} layoutSize="full" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,14 @@ function buildVariantPayloadFromRow(row) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Gemeinsame Felder für „Variante bearbeiten“ und „Neue Variante“. */
|
/** Gemeinsame Felder für „Variante bearbeiten“ und „Neue Variante“. */
|
||||||
function ExerciseVariantFields({ row, onPatch, prerequisiteOthers, rteMinHeight = '110px', exerciseMediaInsertSlots }) {
|
function ExerciseVariantFields({
|
||||||
|
row,
|
||||||
|
onPatch,
|
||||||
|
prerequisiteOthers,
|
||||||
|
rteMinHeight = '110px',
|
||||||
|
inlineExerciseId,
|
||||||
|
onExerciseMediaListChanged,
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
|
|
@ -198,7 +205,8 @@ function ExerciseVariantFields({ row, onPatch, prerequisiteOthers, rteMinHeight
|
||||||
onChange={(html) => onPatch({ execution_changes: html })}
|
onChange={(html) => onPatch({ execution_changes: html })}
|
||||||
placeholder="Was unterscheidet diese Variante? (Listen über Symbolleiste)"
|
placeholder="Was unterscheidet diese Variante? (Listen über Symbolleiste)"
|
||||||
minHeight={rteMinHeight}
|
minHeight={rteMinHeight}
|
||||||
insertExerciseMediaSlots={exerciseMediaInsertSlots}
|
inlineExerciseId={inlineExerciseId}
|
||||||
|
onExerciseMediaListChanged={onExerciseMediaListChanged}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||||||
|
|
@ -460,16 +468,6 @@ function ExerciseFormPage() {
|
||||||
const [archiveError, setArchiveError] = useState(null)
|
const [archiveError, setArchiveError] = useState(null)
|
||||||
const [mediaPreview, setMediaPreview] = useState(null)
|
const [mediaPreview, setMediaPreview] = useState(null)
|
||||||
|
|
||||||
const exerciseMediaInsertSlots = useMemo(() => {
|
|
||||||
if (!isEdit) return []
|
|
||||||
return (mediaList || [])
|
|
||||||
.filter((m) => m?.id != null)
|
|
||||||
.map((m) => ({
|
|
||||||
id: m.id,
|
|
||||||
label: (m.title && String(m.title).trim()) || m.original_filename || `Medium #${m.id}`,
|
|
||||||
}))
|
|
||||||
}, [isEdit, mediaList])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const next = {}
|
const next = {}
|
||||||
for (const m of mediaList) {
|
for (const m of mediaList) {
|
||||||
|
|
@ -1051,7 +1049,8 @@ function ExerciseFormPage() {
|
||||||
onChange={(html) => updateFormField('summary', html)}
|
onChange={(html) => updateFormField('summary', html)}
|
||||||
placeholder="Kurzbeschreibung (optional)"
|
placeholder="Kurzbeschreibung (optional)"
|
||||||
minHeight="80px"
|
minHeight="80px"
|
||||||
insertExerciseMediaSlots={exerciseMediaInsertSlots}
|
inlineExerciseId={isEdit ? exerciseId : null}
|
||||||
|
onExerciseMediaListChanged={refreshMedia}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1062,7 +1061,8 @@ function ExerciseFormPage() {
|
||||||
onChange={(html) => updateFormField('goal', html)}
|
onChange={(html) => updateFormField('goal', html)}
|
||||||
placeholder="Trainingsziel"
|
placeholder="Trainingsziel"
|
||||||
minHeight="120px"
|
minHeight="120px"
|
||||||
insertExerciseMediaSlots={exerciseMediaInsertSlots}
|
inlineExerciseId={isEdit ? exerciseId : null}
|
||||||
|
onExerciseMediaListChanged={refreshMedia}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1073,7 +1073,8 @@ function ExerciseFormPage() {
|
||||||
onChange={(html) => updateFormField('execution', html)}
|
onChange={(html) => updateFormField('execution', html)}
|
||||||
placeholder="Ablauf Schritt für Schritt"
|
placeholder="Ablauf Schritt für Schritt"
|
||||||
minHeight="180px"
|
minHeight="180px"
|
||||||
insertExerciseMediaSlots={exerciseMediaInsertSlots}
|
inlineExerciseId={isEdit ? exerciseId : null}
|
||||||
|
onExerciseMediaListChanged={refreshMedia}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1084,7 +1085,8 @@ function ExerciseFormPage() {
|
||||||
onChange={(html) => updateFormField('preparation', html)}
|
onChange={(html) => updateFormField('preparation', html)}
|
||||||
placeholder="Matten, Raum, …"
|
placeholder="Matten, Raum, …"
|
||||||
minHeight="100px"
|
minHeight="100px"
|
||||||
insertExerciseMediaSlots={exerciseMediaInsertSlots}
|
inlineExerciseId={isEdit ? exerciseId : null}
|
||||||
|
onExerciseMediaListChanged={refreshMedia}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1095,7 +1097,8 @@ function ExerciseFormPage() {
|
||||||
onChange={(html) => updateFormField('trainer_notes', html)}
|
onChange={(html) => updateFormField('trainer_notes', html)}
|
||||||
placeholder="Sicherheit, Varianten-Hinweise, …"
|
placeholder="Sicherheit, Varianten-Hinweise, …"
|
||||||
minHeight="100px"
|
minHeight="100px"
|
||||||
insertExerciseMediaSlots={exerciseMediaInsertSlots}
|
inlineExerciseId={isEdit ? exerciseId : null}
|
||||||
|
onExerciseMediaListChanged={refreshMedia}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1390,7 +1393,8 @@ function ExerciseFormPage() {
|
||||||
onPatch={(patch) => setVariantDraft((d) => ({ ...d, ...patch }))}
|
onPatch={(patch) => setVariantDraft((d) => ({ ...d, ...patch }))}
|
||||||
prerequisiteOthers={variants}
|
prerequisiteOthers={variants}
|
||||||
rteMinHeight="110px"
|
rteMinHeight="110px"
|
||||||
exerciseMediaInsertSlots={exerciseMediaInsertSlots}
|
inlineExerciseId={isEdit ? exerciseId : null}
|
||||||
|
onExerciseMediaListChanged={refreshMedia}
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="btn btn-primary" style={{ marginTop: '10px' }} disabled={variantBusy}>
|
<button type="submit" className="btn btn-primary" style={{ marginTop: '10px' }} disabled={variantBusy}>
|
||||||
{variantBusy ? 'Anlegen…' : 'Variante anlegen'}
|
{variantBusy ? 'Anlegen…' : 'Variante anlegen'}
|
||||||
|
|
@ -1457,7 +1461,8 @@ function ExerciseFormPage() {
|
||||||
onPatch={(patch) => updateVariantField(selectedVariantForEdit.id, patch)}
|
onPatch={(patch) => updateVariantField(selectedVariantForEdit.id, patch)}
|
||||||
prerequisiteOthers={variants.filter((o) => o.id !== selectedVariantForEdit.id)}
|
prerequisiteOthers={variants.filter((o) => o.id !== selectedVariantForEdit.id)}
|
||||||
rteMinHeight="110px"
|
rteMinHeight="110px"
|
||||||
exerciseMediaInsertSlots={exerciseMediaInsertSlots}
|
inlineExerciseId={isEdit ? exerciseId : null}
|
||||||
|
onExerciseMediaListChanged={refreshMedia}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ function isHttpsUrl(val) {
|
||||||
return s.startsWith('http://') || s.startsWith('https://')
|
return s.startsWith('http://') || s.startsWith('https://')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Nur für unsere Embed-Markierung: erlaubt data-attribut und optionale Marker-Klasse. */
|
/** Nur für unsere Embed-Markierung: erlaubt data-attribut und optionale Marker-Klasse + Größe. */
|
||||||
|
const _SIZE_OK = new Set(['small', 'medium', 'full'])
|
||||||
function isInlineExerciseMediaPlaceholderSpan(el) {
|
function isInlineExerciseMediaPlaceholderSpan(el) {
|
||||||
if (!el?.getAttribute || el.tagName.toLowerCase() !== 'span') return false
|
if (!el?.getAttribute || el.tagName.toLowerCase() !== 'span') return false
|
||||||
const raw = el.getAttribute('data-shinkan-exercise-media')
|
const raw = el.getAttribute('data-shinkan-exercise-media')
|
||||||
|
|
@ -33,6 +34,10 @@ function sanitizeAttributes(el, tagLower) {
|
||||||
if (tagLower === 'span' && isInlineExerciseMediaPlaceholderSpan(el)) {
|
if (tagLower === 'span' && isInlineExerciseMediaPlaceholderSpan(el)) {
|
||||||
const out = document.createElement('span')
|
const out = document.createElement('span')
|
||||||
out.setAttribute('data-shinkan-exercise-media', el.getAttribute('data-shinkan-exercise-media').trim())
|
out.setAttribute('data-shinkan-exercise-media', el.getAttribute('data-shinkan-exercise-media').trim())
|
||||||
|
const sz = (el.getAttribute('data-shinkan-exercise-media-size') || '').trim().toLowerCase()
|
||||||
|
if (sz && _SIZE_OK.has(sz)) {
|
||||||
|
out.setAttribute('data-shinkan-exercise-media-size', sz)
|
||||||
|
}
|
||||||
const cls = (el.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean)
|
const cls = (el.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean)
|
||||||
const keep = cls.filter((c) => c === 'shinkan-inline-media')
|
const keep = cls.filter((c) => c === 'shinkan-inline-media')
|
||||||
if (keep.length) out.setAttribute('class', keep.join(' '))
|
if (keep.length) out.setAttribute('class', keep.join(' '))
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user