+
- {media.embed_url}
+ {media.title?.trim() || media.embed_url}
{media.embed_platform && (
-
+
({media.embed_platform})
)}
@@ -25,19 +41,31 @@ export default function ExerciseMediaEmbed({ exerciseId, media }) {
if (!src) return null
if (media.media_type === 'image' || (media.mime_type && media.mime_type.startsWith('image/'))) {
return (
-
+
+

+
)
}
if (media.media_type === 'video' || (media.mime_type && media.mime_type.startsWith('video/'))) {
- return
+ return (
+
+
+
+ )
}
return (
-
- {media.title || media.original_filename || 'Datei öffnen'}
-
+
)
}
diff --git a/frontend/src/components/ExerciseRichTextBlock.jsx b/frontend/src/components/ExerciseRichTextBlock.jsx
index 8845292..7ba4078 100644
--- a/frontend/src/components/ExerciseRichTextBlock.jsx
+++ b/frontend/src/components/ExerciseRichTextBlock.jsx
@@ -44,15 +44,23 @@ function domToReactNodes(node, exerciseId, mediaById, path) {
)
}
+ 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()
return (
-
+
{lc === 'trash_soft' && (
Dieses Medium ist im Papierkorb.
)}
-
+
)
}
diff --git a/frontend/src/components/RichTextEditor.jsx b/frontend/src/components/RichTextEditor.jsx
index 3999531..d72ed32 100644
--- a/frontend/src/components/RichTextEditor.jsx
+++ b/frontend/src/components/RichTextEditor.jsx
@@ -1,4 +1,6 @@
import React, { useRef, useEffect, useState, useCallback } from 'react'
+import ExerciseInlineFileMediaModal from './ExerciseInlineFileMediaModal'
+import ExerciseInlineEmbedModal from './ExerciseInlineEmbedModal'
function exec(cmd, value = null) {
try {
@@ -45,10 +47,17 @@ function normalText() {
formatBlock('p')
}
-function insertExerciseMediaPlaceholder(editorEl, mediaId) {
- if (!editorEl || mediaId == null) return false
+function buildInlineExerciseMediaHtml(mediaId, displaySize = 'medium') {
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 `\u2060`
+}
+
+function insertExerciseMediaPlaceholder(editorEl, mediaId, displaySize = 'medium') {
+ if (!editorEl || mediaId == null) return false
+ const html = buildInlineExerciseMediaHtml(mediaId, displaySize)
+ if (!html) return false
editorEl.focus()
const sel = window.getSelection()
@@ -75,7 +84,6 @@ function insertExerciseMediaPlaceholder(editorEl, mediaId) {
sel.addRange(anchor)
}
- const html = `\u2060`
let inserted = false
try {
inserted = document.execCommand('insertHTML', false, html)
@@ -98,10 +106,10 @@ function insertExerciseMediaPlaceholder(editorEl, mediaId) {
sel.addRange(range)
}
try {
- const span = document.createElement('span')
- span.setAttribute('data-shinkan-exercise-media', String(sid))
- span.className = 'shinkan-inline-media'
- span.appendChild(document.createTextNode('\u2060'))
+ const tpl = document.createElement('template')
+ tpl.innerHTML = html
+ const span = tpl.content.firstChild
+ if (!span) return false
range.deleteContents()
range.insertNode(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).
- * @param {{ id: number, label: string }[]} [insertExerciseMediaSlots] — §11 Verweise auf exercise_media.id
+ * Leichter WYSIWYG (contenteditable).
+ * @param {{ inlineExerciseId?: number|null, onExerciseMediaListChanged?: () => Promise }} [extra]
*/
export default function RichTextEditor({
value,
onChange,
placeholder,
minHeight = '140px',
- insertExerciseMediaSlots,
+ inlineExerciseId = null,
+ onExerciseMediaListChanged,
}) {
const ref = useRef(null)
+ const pendingRangeRef = useRef(null)
const [focused, setFocused] = useState(false)
+ const [fileModalOpen, setFileModalOpen] = useState(false)
+ const [embedModalOpen, setEmbedModalOpen] = useState(false)
useEffect(() => {
const el = ref.current
@@ -142,6 +154,41 @@ export default function RichTextEditor({
onChange(ref.current.innerHTML)
}, [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) => {
e.preventDefault()
e.stopPropagation()
@@ -174,57 +221,7 @@ export default function RichTextEditor({
}
}
- const showMediaPick = Array.isArray(insertExerciseMediaSlots) && insertExerciseMediaSlots.length > 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()
- })
- }
+ const showInlineToolbar = inlineExerciseId != null && Number(inlineExerciseId) > 0
return (
@@ -261,15 +258,25 @@ export default function RichTextEditor({
- {showMediaPick ? (
-
+ {showInlineToolbar ? (
+ <>
+
+
+ >
) : null}
+
+ {showInlineToolbar ? (
+ setFileModalOpen(false)}
+ exerciseId={Number(inlineExerciseId)}
+ onMediaListChanged={refreshExerciseMedia}
+ onInserted={(mid, sz) => finalizeInsertFromModal(mid, sz)}
+ />
+ ) : null}
+ {showInlineToolbar ? (
+ setEmbedModalOpen(false)}
+ exerciseId={Number(inlineExerciseId)}
+ onMediaListChanged={refreshExerciseMedia}
+ onInserted={(mid, sz) => finalizeInsertFromModal(mid, sz)}
+ />
+ ) : null}
)
}
diff --git a/frontend/src/constants/inlineExerciseMedia.js b/frontend/src/constants/inlineExerciseMedia.js
new file mode 100644
index 0000000..b7c3676
--- /dev/null
+++ b/frontend/src/constants/inlineExerciseMedia.js
@@ -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
+}
diff --git a/frontend/src/pages/ExerciseDetailPage.jsx b/frontend/src/pages/ExerciseDetailPage.jsx
index 0d6bc17..8044b28 100644
--- a/frontend/src/pages/ExerciseDetailPage.jsx
+++ b/frontend/src/pages/ExerciseDetailPage.jsx
@@ -180,7 +180,7 @@ function ExerciseDetailPage() {
)}
{m.description && {m.description}
}
-
+
))}
diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx
index 884ebc9..2269987 100644
--- a/frontend/src/pages/ExerciseFormPage.jsx
+++ b/frontend/src/pages/ExerciseFormPage.jsx
@@ -169,7 +169,14 @@ function buildVariantPayloadFromRow(row) {
}
/** 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 (
<>
@@ -198,7 +205,8 @@ function ExerciseVariantFields({ row, onPatch, prerequisiteOthers, rteMinHeight
onChange={(html) => onPatch({ execution_changes: html })}
placeholder="Was unterscheidet diese Variante? (Listen über Symbolleiste)"
minHeight={rteMinHeight}
- insertExerciseMediaSlots={exerciseMediaInsertSlots}
+ inlineExerciseId={inlineExerciseId}
+ onExerciseMediaListChanged={onExerciseMediaListChanged}
/>
@@ -460,16 +468,6 @@ function ExerciseFormPage() {
const [archiveError, setArchiveError] = 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(() => {
const next = {}
for (const m of mediaList) {
@@ -1051,7 +1049,8 @@ function ExerciseFormPage() {
onChange={(html) => updateFormField('summary', html)}
placeholder="Kurzbeschreibung (optional)"
minHeight="80px"
- insertExerciseMediaSlots={exerciseMediaInsertSlots}
+ inlineExerciseId={isEdit ? exerciseId : null}
+ onExerciseMediaListChanged={refreshMedia}
/>
@@ -1062,7 +1061,8 @@ function ExerciseFormPage() {
onChange={(html) => updateFormField('goal', html)}
placeholder="Trainingsziel"
minHeight="120px"
- insertExerciseMediaSlots={exerciseMediaInsertSlots}
+ inlineExerciseId={isEdit ? exerciseId : null}
+ onExerciseMediaListChanged={refreshMedia}
/>
@@ -1073,7 +1073,8 @@ function ExerciseFormPage() {
onChange={(html) => updateFormField('execution', html)}
placeholder="Ablauf Schritt für Schritt"
minHeight="180px"
- insertExerciseMediaSlots={exerciseMediaInsertSlots}
+ inlineExerciseId={isEdit ? exerciseId : null}
+ onExerciseMediaListChanged={refreshMedia}
/>
@@ -1084,7 +1085,8 @@ function ExerciseFormPage() {
onChange={(html) => updateFormField('preparation', html)}
placeholder="Matten, Raum, …"
minHeight="100px"
- insertExerciseMediaSlots={exerciseMediaInsertSlots}
+ inlineExerciseId={isEdit ? exerciseId : null}
+ onExerciseMediaListChanged={refreshMedia}
/>
@@ -1095,7 +1097,8 @@ function ExerciseFormPage() {
onChange={(html) => updateFormField('trainer_notes', html)}
placeholder="Sicherheit, Varianten-Hinweise, …"
minHeight="100px"
- insertExerciseMediaSlots={exerciseMediaInsertSlots}
+ inlineExerciseId={isEdit ? exerciseId : null}
+ onExerciseMediaListChanged={refreshMedia}
/>
@@ -1390,7 +1393,8 @@ function ExerciseFormPage() {
onPatch={(patch) => setVariantDraft((d) => ({ ...d, ...patch }))}
prerequisiteOthers={variants}
rteMinHeight="110px"
- exerciseMediaInsertSlots={exerciseMediaInsertSlots}
+ inlineExerciseId={isEdit ? exerciseId : null}
+ onExerciseMediaListChanged={refreshMedia}
/>