From 337f29401b662c7671c9b47059a9b4e17b6c57fd Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 8 May 2026 12:20:24 +0200 Subject: [PATCH] feat(exercises): update inline media functionality and version bump to 0.8.63 - Incremented application version to 0.8.63 and updated changelog with new features. - Enhanced inline media handling in the Rich Text Editor, including support for captions. - Introduced new CSS styles for improved media display and layout in the editor. - Replaced `ExerciseMediaEmbed` with `ExerciseAttachmentMediaStrip` for better media management in exercise content. --- backend/version.py | 9 +- frontend/src/app.css | 189 +++++++ .../ExerciseAttachmentMediaStrip.jsx | 123 +++++ .../src/components/ExerciseFullContent.jsx | 26 +- .../components/ExerciseInlineEmbedModal.jsx | 8 +- .../ExerciseInlineFileMediaModal.jsx | 110 +++- .../src/components/ExerciseMediaThumbTile.jsx | 68 +++ frontend/src/components/RichTextEditor.jsx | 163 +++++- frontend/src/pages/ExerciseDetailPage.jsx | 24 +- frontend/src/pages/ExerciseFormPage.jsx | 487 ++++++------------ frontend/src/utils/exerciseInlineMediaRefs.js | 85 +++ .../src/utils/exerciseRichTextSanitize.js | 7 + frontend/src/utils/inlineMediaCaption.js | 17 + 13 files changed, 887 insertions(+), 429 deletions(-) create mode 100644 frontend/src/components/ExerciseAttachmentMediaStrip.jsx create mode 100644 frontend/src/components/ExerciseMediaThumbTile.jsx create mode 100644 frontend/src/utils/exerciseInlineMediaRefs.js create mode 100644 frontend/src/utils/inlineMediaCaption.js diff --git a/backend/version.py b/backend/version.py index abd5b38..e26af3f 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.62" +APP_VERSION = "0.8.63" BUILD_DATE = "2026-05-08" DB_SCHEMA_VERSION = "20260508049" @@ -29,6 +29,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.63", + "date": "2026-05-08", + "changes": [ + "RTE/Übung Medien: Picker-Thumbnails; Dateiauswahl-Anzeige; bereits verknüpfte Archive-Medien ins Fließtext einfügen; Platzhalter-Caption data-shinkan-exercise-media-caption + Caret nach ZWSP; Lesemodus: Medienliste nur für nicht eingebettete Anhänge; bearbeiten: kompakte Kacheln mit Drag-and-Drop in Textfelder, Upload unter Medien entfällt", + ], + }, { "version": "0.8.62", "date": "2026-05-08", diff --git a/frontend/src/app.css b/frontend/src/app.css index 320055f..aad7f2a 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -3914,6 +3914,9 @@ a.analysis-split__nav-item { .rich-text-editor span.shinkan-inline-media::before { content: '📎 #' attr(data-shinkan-exercise-media) ' · ' attr(data-shinkan-exercise-media-size); } +.rich-text-editor span.shinkan-inline-media[data-shinkan-exercise-media-caption]::before { + content: '📎 ' attr(data-shinkan-exercise-media-caption) ' · #' attr(data-shinkan-exercise-media) ' · ' attr(data-shinkan-exercise-media-size); +} /* Listen im Editor */ .rich-text-editor ul, @@ -3998,6 +4001,192 @@ a.analysis-split__nav-item { overflow: hidden; } +.rte-inline-asset-tile__thumb { + width: 100%; + aspect-ratio: 4 / 3; + border-radius: 6px; + overflow: hidden; + background: var(--surface2); + border: 1px solid rgba(127, 127, 127, 0.12); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 2px; +} +.rte-inline-asset-tile__thumb-img { + width: 100%; + height: 100%; + object-fit: cover; +} +.rte-inline-asset-tile__thumb-fallback { + font-size: 11px; + color: var(--text3); +} +.rte-inline-asset-tile__badge { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--accent-dark); +} + +.rte-inline-file-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + position: relative; +} +.rte-inline-file-input-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +.rte-inline-file-pick-btn { + flex-shrink: 0; + cursor: pointer; + margin: 0; +} +.rte-inline-file-name { + flex: 1 1 160px; + font-size: 13px; + color: var(--text2); + line-height: 1.35; + min-width: 0; + word-break: break-word; +} + +.exercise-edit-media-strip { + list-style: none; + padding: 0; + margin: 14px 0 0; + display: flex; + flex-direction: column; + gap: 10px; +} +.exercise-edit-media-strip__item { + display: flex; + gap: 12px; + align-items: stretch; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); +} +.exercise-edit-media-strip__lead { + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + width: 96px; +} +.exercise-edit-media-strip__handle { + width: 100%; + text-align: center; + font-size: 14px; + line-height: 1.2; + padding: 6px 4px; + border-radius: 8px; + border: 1px dashed var(--accent); + background: rgba(29, 158, 117, 0.08); + color: var(--accent-dark); + cursor: grab; + user-select: none; +} +.exercise-edit-media-strip__handle:active { + cursor: grabbing; +} +.exercise-edit-media-strip__handle-text { + font-size: 11px; + font-weight: 600; +} +.exercise-edit-media-strip__embed-badge--solo { + width: 76px; + min-height: 76px; + border-radius: 8px; + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + background: var(--surface2); + font-size: 12px; + font-weight: 600; + color: var(--text2); + padding: 6px; + text-align: center; +} + +.exercise-edit-media-strip__body { + flex: 1; + min-width: 0; +} +.exercise-edit-media-strip__toolbar { + display: grid; + grid-template-columns: 1fr minmax(120px, 160px); + gap: 8px; + margin-top: 8px; +} +@media (max-width: 520px) { + .exercise-edit-media-strip__toolbar { + grid-template-columns: 1fr; + } +} +.exercise-edit-media-strip__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; + align-items: center; +} + +.exercise-orphan-media-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 12px; + margin-top: 12px; +} +.exercise-orphan-media-card { + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 12px; + background: var(--surface); +} +.exercise-orphan-media-card__head { + display: flex; + gap: 10px; + align-items: flex-start; + margin-bottom: 8px; +} +.exercise-orphan-media-card__meta { + flex: 1; + min-width: 0; +} +.exercise-orphan-media-card__title { + font-size: 14px; + display: block; + line-height: 1.3; + word-break: break-word; +} +.exercise-orphan-media-card__sub { + display: block; + font-size: 11px; + color: var(--text3); + margin-top: 4px; +} +.exercise-orphan-media-card__warn { + display: block; + font-size: 11px; + color: var(--danger); + margin-top: 4px; +} + .rich-text-content { font-size: 16px; line-height: 1.55; diff --git a/frontend/src/components/ExerciseAttachmentMediaStrip.jsx b/frontend/src/components/ExerciseAttachmentMediaStrip.jsx new file mode 100644 index 0000000..503868a --- /dev/null +++ b/frontend/src/components/ExerciseAttachmentMediaStrip.jsx @@ -0,0 +1,123 @@ +/** + * Nur Medien, die noch nicht im Fließtext eingebettet sind — ohne Doppel-Darstellung. + */ +import React, { useMemo, useState } from 'react' +import ExerciseMediaEmbed from './ExerciseMediaEmbed' +import ExerciseMediaThumbTile from './ExerciseMediaThumbTile' +import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl' +import { + collectInlineExerciseMediaIdsFromExercise, +} from '../utils/exerciseInlineMediaRefs' + +function isTrashHidden(m) { + return String(m?.asset_lifecycle_state || 'active').toLowerCase() === 'trash_hidden' +} + +export default function ExerciseAttachmentMediaStrip({ exerciseId, exercise }) { + const [preview, setPreview] = useState(null) + const inlineIds = useMemo(() => collectInlineExerciseMediaIdsFromExercise(exercise), [exercise]) + + const orphans = useMemo(() => { + const list = (exercise?.media || []).filter((m) => m && !isTrashHidden(m)) + return list.filter((m) => !inlineIds.has(Number(m.id))) + }, [exercise, inlineIds]) + + if (!orphans.length || exerciseId == null) return null + + return ( +
+

Angehängte Medien

+

+ Hier erscheinen nur Verknüpfungen, die noch nicht im Fließtext eingebettet sind (reine Material-Anhänge). +

+
+ {orphans.map((m) => { + const lc = String(m.asset_lifecycle_state || 'active').toLowerCase() + const caption = (m.title || '').trim() || (m.original_filename || '').trim() || `Medium #${m.id}` + return ( +
+
+ +
+ {caption} + + #{m.id} + {m.embed_platform ? ` · ${m.embed_platform}` : ''} + {m.media_type ? ` · ${m.media_type}` : ''} + + {lc === 'trash_soft' && ( + Papierkorb (Stufe 1) + )} +
+
+ +
+ ) + })} +
+ {preview && ( +
setPreview(null)} + onKeyDown={(e) => e.key === 'Escape' && setPreview(null)} + > +
e.stopPropagation()} + > +

Vorschau

+ {preview.embed_url ? ( +

+ + {preview.embed_url} + +

+ ) : preview.mime_type?.startsWith('video/') || preview.media_type === 'video' ? ( +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/ExerciseFullContent.jsx b/frontend/src/components/ExerciseFullContent.jsx index 4fd12ff..70b83e4 100644 --- a/frontend/src/components/ExerciseFullContent.jsx +++ b/frontend/src/components/ExerciseFullContent.jsx @@ -4,7 +4,7 @@ import React from 'react' import { Link } from 'react-router-dom' import ExerciseRichTextBlock from './ExerciseRichTextBlock' -import ExerciseMediaEmbed from './ExerciseMediaEmbed' +import ExerciseAttachmentMediaStrip from './ExerciseAttachmentMediaStrip' function TagRow({ exercise }) { const tags = [] @@ -70,10 +70,6 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise const resolvedId = exercise.id ?? exerciseId const meta = metaParts(exercise) - const visibleMedia = (exercise.media || []).filter((m) => { - const lc = String(m.asset_lifecycle_state || 'active').toLowerCase() - return lc !== 'trash_hidden' - }) return (
@@ -122,25 +118,7 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise )} - {visibleMedia.length > 0 && ( -
-

- Medien -

- {visibleMedia.map((m) => ( -
- {m.title || m.original_filename || m.media_type} - {String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_soft' && ( -

- Hinweis: Dieses Medium ist im Papierkorb und steht künftig nicht mehr zur Verfügung. -

- )} - {m.description &&

{m.description}

} - -
- ))} -
- )} + {exercise.trainer_notes && (

diff --git a/frontend/src/components/ExerciseInlineEmbedModal.jsx b/frontend/src/components/ExerciseInlineEmbedModal.jsx index abf26ca..f54e912 100644 --- a/frontend/src/components/ExerciseInlineEmbedModal.jsx +++ b/frontend/src/components/ExerciseInlineEmbedModal.jsx @@ -8,6 +8,7 @@ import { DEFAULT_INLINE_MEDIA_SIZE, sanitizeInlineMediaSize, } from '../constants/inlineExerciseMedia' +import { sanitizeInlineMediaCaption } from '../utils/inlineMediaCaption' /** * @param {{ @@ -15,7 +16,7 @@ import { * onClose: () => void, * exerciseId: number, * onMediaListChanged: () => Promise, - * onInserted: (exerciseMediaId: number, displaySize: string) => void, + * onInserted: (exerciseMediaId: number, displaySize: string, caption?: string) => void, * }} props */ export default function ExerciseInlineEmbedModal({ @@ -59,7 +60,10 @@ export default function ExerciseInlineEmbedModal({ throw new Error('Antwort ohne exercise_media-ID') } await onMediaListChanged() - onInserted(Number(mid), size) + const cap = sanitizeInlineMediaCaption( + title.trim() || u.replace(/^https?:\/\//i, '').slice(0, 96), + ) + onInserted(Number(mid), size, cap) onClose() } catch (e) { alert(e.message || String(e)) diff --git a/frontend/src/components/ExerciseInlineFileMediaModal.jsx b/frontend/src/components/ExerciseInlineFileMediaModal.jsx index bf6290c..7662079 100644 --- a/frontend/src/components/ExerciseInlineFileMediaModal.jsx +++ b/frontend/src/components/ExerciseInlineFileMediaModal.jsx @@ -1,13 +1,15 @@ /** * Modal: Medium aus Archiv verknüpfen oder neue Datei hochladen, dann Inline-Platzhalter §11 einfügen. */ -import React, { useEffect, useState, useCallback } from 'react' +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' /** MIME/Dateiname → Übungs-media_type */ function inferExerciseMediaType(file) { @@ -28,14 +30,16 @@ function inferExerciseMediaType(file) { * open: boolean, * onClose: () => void, * exerciseId: number, + * linkedExerciseMedia?: object[], * onMediaListChanged: () => Promise, - * onInserted: (exerciseMediaId: number, displaySize: string) => void, + * onInserted: (exerciseMediaId: number, displaySize: string, caption?: string) => void, * }} props */ export default function ExerciseInlineFileMediaModal({ open, onClose, exerciseId, + linkedExerciseMedia = [], onMediaListChanged, onInserted, }) { @@ -49,6 +53,16 @@ export default function ExerciseInlineFileMediaModal({ const [uploadFile, setUploadFile] = useState(null) const [uploadTitle, setUploadTitle] = useState('') const [displaySize, setDisplaySize] = useState(DEFAULT_INLINE_MEDIA_SIZE) + const [uploadInputKey, setUploadInputKey] = useState(0) + + 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) @@ -74,6 +88,7 @@ export default function ExerciseInlineFileMediaModal({ setSelectedAssetId(null) setUploadFile(null) setUploadTitle('') + setUploadInputKey((k) => k + 1) setDisplaySize(DEFAULT_INLINE_MEDIA_SIZE) setErr(null) const t = setTimeout(loadAssets, 280) @@ -92,6 +107,17 @@ export default function ExerciseInlineFileMediaModal({ 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 { @@ -107,12 +133,15 @@ export default function ExerciseInlineFileMediaModal({ throw new Error('Antwort ohne exercise_media-ID') } await onMediaListChanged() - onInserted(Number(mid), size) + onInserted( + Number(mid), + size, + sanitizeInlineMediaCaption(assetMeta?.original_filename || ''), + ) onClose() } catch (e) { const msg = e.message || String(e) setErr(msg) - alert(msg) } finally { setBusy(false) } @@ -141,9 +170,13 @@ export default function ExerciseInlineFileMediaModal({ throw new Error('Antwort ohne exercise_media-ID') } await onMediaListChanged() - onInserted(Number(mid), size) + 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) { @@ -159,6 +192,8 @@ export default function ExerciseInlineFileMediaModal({ } } + const selectedLinked = selectedAssetId != null && assetToExerciseMedia.has(Number(selectedAssetId)) + if (!open) return null return ( @@ -208,6 +243,9 @@ export default function ExerciseInlineFileMediaModal({

+ {err && !loading ? ( +

{err}

+ ) : null} {tab === 'library' && ( <> @@ -219,14 +257,15 @@ export default function ExerciseInlineFileMediaModal({ 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}` + const linked = assetToExerciseMedia.has(Number(id)) + const src = resolveMediaAssetFileUrl(id) + const isImg = (it.mime_type || '').startsWith('image/') + const isVid = (it.mime_type || '').startsWith('video/') return (
{tab === 'library' ? ( - ) : (
@@ -1074,6 +917,7 @@ function ExerciseFormPage() { placeholder="Ablauf Schritt für Schritt" minHeight="180px" inlineExerciseId={isEdit ? exerciseId : null} + linkedExerciseMedia={isEdit ? mediaList : []} onExerciseMediaListChanged={refreshMedia} /> @@ -1086,6 +930,7 @@ function ExerciseFormPage() { placeholder="Matten, Raum, …" minHeight="100px" inlineExerciseId={isEdit ? exerciseId : null} + linkedExerciseMedia={isEdit ? mediaList : []} onExerciseMediaListChanged={refreshMedia} /> @@ -1098,6 +943,7 @@ function ExerciseFormPage() { placeholder="Sicherheit, Varianten-Hinweise, …" minHeight="100px" inlineExerciseId={isEdit ? exerciseId : null} + linkedExerciseMedia={isEdit ? mediaList : []} onExerciseMediaListChanged={refreshMedia} /> @@ -1394,6 +1240,7 @@ function ExerciseFormPage() { prerequisiteOthers={variants} rteMinHeight="110px" inlineExerciseId={isEdit ? exerciseId : null} + linkedExerciseMedia={isEdit ? mediaList : []} onExerciseMediaListChanged={refreshMedia} /> - -
- - setEmbedUrl(e.target.value)} - /> - setEmbedTitle(e.target.value)} - style={{ marginTop: '8px' }} - /> - -
- {mediaList.length > 0 && ( - )} {archiveOpen && ( diff --git a/frontend/src/utils/exerciseInlineMediaRefs.js b/frontend/src/utils/exerciseInlineMediaRefs.js new file mode 100644 index 0000000..d527044 --- /dev/null +++ b/frontend/src/utils/exerciseInlineMediaRefs.js @@ -0,0 +1,85 @@ +/** + * §11 Inline-Medien: aus HTML / Übungsobjekt referenzierte exercise_media-IDs sammeln. + */ + +const DATA_ATTR_RE = /data-shinkan-exercise-media\s*=\s*["']?(\d+)/gi + +export const SHINKAN_EXERCISE_MEDIA_DRAG_MIME = 'application/x-shinkan-exercise-media' + +/** + * @param {string|null|undefined} html + * @returns {Set} + */ +export function collectInlineExerciseMediaIdsFromHtml(html) { + const ids = new Set() + if (!html || typeof html !== 'string') return ids + let m + const re = new RegExp(DATA_ATTR_RE.source, 'gi') + while ((m = re.exec(html)) !== null) { + const n = parseInt(m[1], 10) + if (Number.isFinite(n) && n > 0) ids.add(n) + } + return ids +} + +const EXERCISE_RTF_FIELDS = ['summary', 'goal', 'execution', 'preparation', 'trainer_notes'] + +/** + * HTML-Schnipsel aus Übung + Varianten-Fließtext für Inline-Scan. + * @param {object|null|undefined} exercise + * @returns {string[]} + */ +export function gatherExerciseHtmlSlicesForInlineScan(exercise) { + if (!exercise || typeof exercise !== 'object') return [] + const slices = [] + for (const f of EXERCISE_RTF_FIELDS) { + const html = exercise[f] + if (typeof html === 'string' && html.trim()) slices.push(html) + } + for (const v of exercise.variants || []) { + const ec = v?.execution_changes + if (typeof ec === 'string' && ec.trim()) slices.push(ec) + } + return slices +} + +/** + * Alle im Fließtext eingebetteten exercise_media-IDs (Übung + Varianten). + * @param {object|null|undefined} exercise + * @returns {Set} + */ +export function collectInlineExerciseMediaIdsFromExercise(exercise) { + const ids = new Set() + for (const html of gatherExerciseHtmlSlicesForInlineScan(exercise)) { + collectInlineExerciseMediaIdsFromHtml(html).forEach((id) => ids.add(id)) + } + return ids +} + +/** + * @param {number} exerciseMediaId + * @param {string} [caption] + */ +export function buildExerciseMediaDragPayload(exerciseMediaId, caption = '') { + return JSON.stringify({ + exerciseMediaId: Number(exerciseMediaId), + caption: typeof caption === 'string' ? caption : '', + }) +} + +/** + * @param {string} raw + * @returns {{ exerciseMediaId: number, caption: string }|null} + */ +export function parseExerciseMediaDragPayload(raw) { + if (!raw || typeof raw !== 'string') return null + try { + const o = JSON.parse(raw) + const id = Number(o.exerciseMediaId) + if (!Number.isFinite(id) || id < 1) return null + const caption = typeof o.caption === 'string' ? o.caption : '' + return { exerciseMediaId: id, caption } + } catch { + return null + } +} diff --git a/frontend/src/utils/exerciseRichTextSanitize.js b/frontend/src/utils/exerciseRichTextSanitize.js index 745f181..6a99dd2 100644 --- a/frontend/src/utils/exerciseRichTextSanitize.js +++ b/frontend/src/utils/exerciseRichTextSanitize.js @@ -3,6 +3,8 @@ * Restriktiver als sanitizeTrainerHtml: Allowlist für XSS-Minimierung. */ +import { sanitizeInlineMediaCaption } from './inlineMediaCaption' + const ALLOWED_BLOCK = new Set(['p', 'div', 'br', 'ul', 'ol', 'li', 'h3']) const ALLOWED_INLINE = new Set(['b', 'strong', 'i', 'em', 'u', 'span', 'a']) @@ -38,6 +40,11 @@ function sanitizeAttributes(el, tagLower) { if (sz && _SIZE_OK.has(sz)) { out.setAttribute('data-shinkan-exercise-media-size', sz) } + const capRaw = el.getAttribute('data-shinkan-exercise-media-caption') + if (capRaw != null && String(capRaw).trim()) { + const cap = sanitizeInlineMediaCaption(String(capRaw)) + if (cap) out.setAttribute('data-shinkan-exercise-media-caption', cap) + } const cls = (el.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean) const keep = cls.filter((c) => c === 'shinkan-inline-media') if (keep.length) out.setAttribute('class', keep.join(' ')) diff --git a/frontend/src/utils/inlineMediaCaption.js b/frontend/src/utils/inlineMediaCaption.js new file mode 100644 index 0000000..be63126 --- /dev/null +++ b/frontend/src/utils/inlineMediaCaption.js @@ -0,0 +1,17 @@ +const MAX_CAPTION = 120 + +/** + * Für data-shinkan-exercise-media-caption: kurz, ohne Anführungszeichen/HTML. + * @param {string|null|undefined} raw + * @returns {string} + */ +export function sanitizeInlineMediaCaption(raw) { + if (raw == null || typeof raw !== 'string') return '' + let s = raw + .replace(/[\u0000-\u001F\u007F]/g, ' ') + .replace(/["'`<>]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + if (s.length > MAX_CAPTION) s = s.slice(0, MAX_CAPTION) + return s +}