From 311a106d93741928de0cf7113aa5e27fdb5ea352 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 8 May 2026 12:00:02 +0200 Subject: [PATCH] feat(exercises): enhance inline media functionality and update styles - 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. --- backend/exercise_rich_text.py | 5 +- backend/tests/test_exercise_rich_text.py | 1 + backend/version.py | 9 +- frontend/src/app.css | 61 +++- .../src/components/ExerciseFullContent.jsx | 2 +- .../components/ExerciseInlineEmbedModal.jsx | 128 ++++++++ .../ExerciseInlineFileMediaModal.jsx | 308 ++++++++++++++++++ .../src/components/ExerciseMediaEmbed.jsx | 56 +++- .../src/components/ExerciseRichTextBlock.jsx | 12 +- frontend/src/components/RichTextEditor.jsx | 168 ++++++---- frontend/src/constants/inlineExerciseMedia.js | 14 + frontend/src/pages/ExerciseDetailPage.jsx | 2 +- frontend/src/pages/ExerciseFormPage.jsx | 43 +-- .../src/utils/exerciseRichTextSanitize.js | 7 +- 14 files changed, 703 insertions(+), 113 deletions(-) create mode 100644 frontend/src/components/ExerciseInlineEmbedModal.jsx create mode 100644 frontend/src/components/ExerciseInlineFileMediaModal.jsx create mode 100644 frontend/src/constants/inlineExerciseMedia.js diff --git a/backend/exercise_rich_text.py b/backend/exercise_rich_text.py index 691facc..0e996a9 100644 --- a/backend/exercise_rich_text.py +++ b/backend/exercise_rich_text.py @@ -35,7 +35,10 @@ def normalize_inline_exercise_media_markup(html: Optional[str]) -> Optional[str] def _repl(match: re.Match) -> str: mid = int(match.group(1)) - return f'' + return ( + f'' + ) return _BRACE_PATTERN.sub(_repl, html) diff --git a/backend/tests/test_exercise_rich_text.py b/backend/tests/test_exercise_rich_text.py index 3ae82e9..612bf36 100644 --- a/backend/tests/test_exercise_rich_text.py +++ b/backend/tests/test_exercise_rich_text.py @@ -18,6 +18,7 @@ def test_normalize_curly_to_span() -> None: s = '

Vor {{exerciseMedia: 42 }} nach

' out = normalize_inline_exercise_media_markup(s) assert 'data-shinkan-exercise-media="42"' in out + assert 'data-shinkan-exercise-media-size="medium"' in out assert "{{" not in out diff --git a/backend/version.py b/backend/version.py index 89e9e53..abd5b38 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.61" +APP_VERSION = "0.8.62" BUILD_DATE = "2026-05-08" DB_SCHEMA_VERSION = "20260508049" @@ -29,6 +29,13 @@ MODULE_VERSIONS = { } 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", "date": "2026-05-08", diff --git a/frontend/src/app.css b/frontend/src/app.css index 31cb8e5..320055f 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -3912,10 +3912,10 @@ a.analysis-split__nav-item { cursor: default; } .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 ol { margin: 0.35rem 0; @@ -3941,6 +3941,63 @@ a.analysis-split__nav-item { 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 { font-size: 16px; line-height: 1.55; diff --git a/frontend/src/components/ExerciseFullContent.jsx b/frontend/src/components/ExerciseFullContent.jsx index 0e1b107..4fd12ff 100644 --- a/frontend/src/components/ExerciseFullContent.jsx +++ b/frontend/src/components/ExerciseFullContent.jsx @@ -136,7 +136,7 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise

)} {m.description &&

{m.description}

} - + ))} diff --git a/frontend/src/components/ExerciseInlineEmbedModal.jsx b/frontend/src/components/ExerciseInlineEmbedModal.jsx new file mode 100644 index 0000000..abf26ca --- /dev/null +++ b/frontend/src/components/ExerciseInlineEmbedModal.jsx @@ -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, + * 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 ( +
e.target === e.currentTarget && !busy && onClose()}> +
e.stopPropagation()} + > +
+

+ Embed im Textfeld +

+ +
+
+ + setUrl(e.target.value)} + disabled={busy} + /> + + setTitle(e.target.value)} + disabled={busy} + /> + + + +
+
+
+ ) +} diff --git a/frontend/src/components/ExerciseInlineFileMediaModal.jsx b/frontend/src/components/ExerciseInlineFileMediaModal.jsx new file mode 100644 index 0000000..bf6290c --- /dev/null +++ b/frontend/src/components/ExerciseInlineFileMediaModal.jsx @@ -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, + * 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 ( +
e.target === e.currentTarget && !busy && onClose()}> +
e.stopPropagation()} + > +
+

+ Medium im Textfeld +

+ +
+ +
+ + +
+ +
+ {tab === 'library' && ( + <> + + setQ(e.target.value)} + placeholder="Name, Tag, © …" + disabled={busy} + /> + {loading ?

Laden…

: null} + {err && tab === 'library' && !loading ? ( +

{err}

+ ) : null} +
+ {items.map((it) => { + const id = it.id + const selected = selectedAssetId === id + const label = it.original_filename || it.copyright_notice || `Archiv #${id}` + return ( + + ) + })} +
+ {!loading && items.length === 0 ? ( +

Keine Treffer — Suche anpassen oder „Neu hochladen“.

+ ) : null} + + )} + + {tab === 'upload' && ( + <> + + { + const f = e.target.files?.[0] || null + setUploadFile(f) + e.target.value = '' + }} + /> + {uploadFile ? ( +

{uploadFile.name}

+ ) : null} + + setUploadTitle(e.target.value)} + disabled={busy} + /> + + )} +
+ +
+
+ + +
+ {tab === 'library' ? ( + + ) : ( + + )} +
+
+
+ ) +} diff --git a/frontend/src/components/ExerciseMediaEmbed.jsx b/frontend/src/components/ExerciseMediaEmbed.jsx index e010fa3..7e3f11e 100644 --- a/frontend/src/components/ExerciseMediaEmbed.jsx +++ b/frontend/src/components/ExerciseMediaEmbed.jsx @@ -1,20 +1,36 @@ import React from 'react' import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl' +import { sanitizeInlineMediaSize } from '../constants/inlineExerciseMedia' /** * 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.embed_url) { return ( -
+
- {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 ( - {media.title +
+ {media.title +
) } if (media.media_type === 'video' || (media.mime_type && media.mime_type.startsWith('video/'))) { - return
) } + 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}
) } 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} />