feat(exercises): update inline media functionality and version bump to 0.8.63
All checks were successful
Deploy Development / deploy (push) Successful in 34s
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 30s
All checks were successful
Deploy Development / deploy (push) Successful in 34s
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 30s
- 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.
This commit is contained in:
parent
311a106d93
commit
337f29401b
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.62"
|
APP_VERSION = "0.8.63"
|
||||||
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.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",
|
"version": "0.8.62",
|
||||||
"date": "2026-05-08",
|
"date": "2026-05-08",
|
||||||
|
|
|
||||||
|
|
@ -3914,6 +3914,9 @@ a.analysis-split__nav-item {
|
||||||
.rich-text-editor span.shinkan-inline-media::before {
|
.rich-text-editor span.shinkan-inline-media::before {
|
||||||
content: '📎 #' attr(data-shinkan-exercise-media) ' · ' attr(data-shinkan-exercise-media-size);
|
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 */
|
/* Listen im Editor */
|
||||||
.rich-text-editor ul,
|
.rich-text-editor ul,
|
||||||
|
|
@ -3998,6 +4001,192 @@ a.analysis-split__nav-item {
|
||||||
overflow: hidden;
|
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 {
|
.rich-text-content {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
|
|
|
||||||
123
frontend/src/components/ExerciseAttachmentMediaStrip.jsx
Normal file
123
frontend/src/components/ExerciseAttachmentMediaStrip.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<section className="card exercise-detail-section exercise-attachment-media-strip">
|
||||||
|
<h2>Angehängte Medien</h2>
|
||||||
|
<p style={{ marginTop: '6px', color: 'var(--text2)', fontSize: '0.88rem' }}>
|
||||||
|
Hier erscheinen nur Verknüpfungen, die noch nicht im Fließtext eingebettet sind (reine Material-Anhänge).
|
||||||
|
</p>
|
||||||
|
<div className="exercise-orphan-media-grid">
|
||||||
|
{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 (
|
||||||
|
<article key={m.id} className="exercise-orphan-media-card">
|
||||||
|
<div className="exercise-orphan-media-card__head">
|
||||||
|
<ExerciseMediaThumbTile
|
||||||
|
exerciseId={exerciseId}
|
||||||
|
media={m}
|
||||||
|
onOpenPreview={setPreview}
|
||||||
|
size={88}
|
||||||
|
/>
|
||||||
|
<div className="exercise-orphan-media-card__meta">
|
||||||
|
<strong className="exercise-orphan-media-card__title">{caption}</strong>
|
||||||
|
<span className="exercise-orphan-media-card__sub">
|
||||||
|
#{m.id}
|
||||||
|
{m.embed_platform ? ` · ${m.embed_platform}` : ''}
|
||||||
|
{m.media_type ? ` · ${m.media_type}` : ''}
|
||||||
|
</span>
|
||||||
|
{lc === 'trash_soft' && (
|
||||||
|
<span className="exercise-orphan-media-card__warn">Papierkorb (Stufe 1)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ExerciseMediaEmbed exerciseId={exerciseId} media={m} layoutSize="medium" />
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{preview && (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Medienvorschau"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0,0,0,0.55)',
|
||||||
|
zIndex: 1001,
|
||||||
|
overflow: 'auto',
|
||||||
|
padding: '16px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
onClick={() => setPreview(null)}
|
||||||
|
onKeyDown={(e) => e.key === 'Escape' && setPreview(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{ maxWidth: 720, width: '100%', maxHeight: '90vh', overflow: 'auto' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 style={{ marginTop: 0, fontSize: '1.05rem' }}>Vorschau</h3>
|
||||||
|
{preview.embed_url ? (
|
||||||
|
<p style={{ fontSize: '14px', wordBreak: 'break-all' }}>
|
||||||
|
<a href={preview.embed_url} target="_blank" rel="noreferrer">
|
||||||
|
{preview.embed_url}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
) : preview.mime_type?.startsWith('video/') || preview.media_type === 'video' ? (
|
||||||
|
<video
|
||||||
|
src={resolveExerciseMediaFileUrl(exerciseId, preview)}
|
||||||
|
controls
|
||||||
|
style={{ width: '100%', borderRadius: '8px', maxHeight: '70vh' }}
|
||||||
|
/>
|
||||||
|
) : preview.mime_type?.startsWith('image/') || preview.media_type === 'image' ? (
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
src={resolveExerciseMediaFileUrl(exerciseId, preview)}
|
||||||
|
style={{ maxWidth: '100%', borderRadius: '8px', maxHeight: '70vh', objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p style={{ fontSize: '14px' }}>
|
||||||
|
<a href={resolveExerciseMediaFileUrl(exerciseId, preview)} target="_blank" rel="noreferrer">
|
||||||
|
Datei öffnen
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div style={{ marginTop: '16px' }}>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => setPreview(null)}>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
|
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
|
||||||
import ExerciseMediaEmbed from './ExerciseMediaEmbed'
|
import ExerciseAttachmentMediaStrip from './ExerciseAttachmentMediaStrip'
|
||||||
|
|
||||||
function TagRow({ exercise }) {
|
function TagRow({ exercise }) {
|
||||||
const tags = []
|
const tags = []
|
||||||
|
|
@ -70,10 +70,6 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
|
||||||
|
|
||||||
const resolvedId = exercise.id ?? exerciseId
|
const resolvedId = exercise.id ?? exerciseId
|
||||||
const meta = metaParts(exercise)
|
const meta = metaParts(exercise)
|
||||||
const visibleMedia = (exercise.media || []).filter((m) => {
|
|
||||||
const lc = String(m.asset_lifecycle_state || 'active').toLowerCase()
|
|
||||||
return lc !== 'trash_hidden'
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="exercise-coach-catalog" style={{ fontSize: '0.93rem', lineHeight: 1.5 }}>
|
<div className="exercise-coach-catalog" style={{ fontSize: '0.93rem', lineHeight: 1.5 }}>
|
||||||
|
|
@ -122,25 +118,7 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
|
||||||
<ExerciseRichTextBlock html={exercise.execution} exerciseId={resolvedId} media={exercise.media} />
|
<ExerciseRichTextBlock html={exercise.execution} exerciseId={resolvedId} media={exercise.media} />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{visibleMedia.length > 0 && (
|
<ExerciseAttachmentMediaStrip exercise={exercise} exerciseId={resolvedId} />
|
||||||
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
|
|
||||||
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>
|
|
||||||
Medien
|
|
||||||
</h3>
|
|
||||||
{visibleMedia.map((m) => (
|
|
||||||
<div key={m.id} style={{ marginBottom: '12px' }}>
|
|
||||||
<strong style={{ fontSize: '0.9rem' }}>{m.title || m.original_filename || m.media_type}</strong>
|
|
||||||
{String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_soft' && (
|
|
||||||
<p style={{ fontSize: '0.75rem', color: 'var(--danger)', margin: '4px 0 0' }}>
|
|
||||||
Hinweis: Dieses Medium ist im Papierkorb und steht künftig nicht mehr zur Verfügung.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{m.description && <p style={{ color: 'var(--text2)', fontSize: '0.82rem', marginTop: '4px' }}>{m.description}</p>}
|
|
||||||
<ExerciseMediaEmbed media={m} exerciseId={resolvedId} layoutSize="full" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
{exercise.trainer_notes && (
|
{exercise.trainer_notes && (
|
||||||
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
|
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
|
||||||
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>
|
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
DEFAULT_INLINE_MEDIA_SIZE,
|
DEFAULT_INLINE_MEDIA_SIZE,
|
||||||
sanitizeInlineMediaSize,
|
sanitizeInlineMediaSize,
|
||||||
} from '../constants/inlineExerciseMedia'
|
} from '../constants/inlineExerciseMedia'
|
||||||
|
import { sanitizeInlineMediaCaption } from '../utils/inlineMediaCaption'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{
|
* @param {{
|
||||||
|
|
@ -15,7 +16,7 @@ import {
|
||||||
* onClose: () => void,
|
* onClose: () => void,
|
||||||
* exerciseId: number,
|
* exerciseId: number,
|
||||||
* onMediaListChanged: () => Promise<void>,
|
* onMediaListChanged: () => Promise<void>,
|
||||||
* onInserted: (exerciseMediaId: number, displaySize: string) => void,
|
* onInserted: (exerciseMediaId: number, displaySize: string, caption?: string) => void,
|
||||||
* }} props
|
* }} props
|
||||||
*/
|
*/
|
||||||
export default function ExerciseInlineEmbedModal({
|
export default function ExerciseInlineEmbedModal({
|
||||||
|
|
@ -59,7 +60,10 @@ export default function ExerciseInlineEmbedModal({
|
||||||
throw new Error('Antwort ohne exercise_media-ID')
|
throw new Error('Antwort ohne exercise_media-ID')
|
||||||
}
|
}
|
||||||
await onMediaListChanged()
|
await onMediaListChanged()
|
||||||
onInserted(Number(mid), size)
|
const cap = sanitizeInlineMediaCaption(
|
||||||
|
title.trim() || u.replace(/^https?:\/\//i, '').slice(0, 96),
|
||||||
|
)
|
||||||
|
onInserted(Number(mid), size, cap)
|
||||||
onClose()
|
onClose()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e.message || String(e))
|
alert(e.message || String(e))
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
/**
|
/**
|
||||||
* Modal: Medium aus Archiv verknüpfen oder neue Datei hochladen, dann Inline-Platzhalter §11 einfügen.
|
* 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 api from '../utils/api'
|
||||||
|
import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
|
||||||
import {
|
import {
|
||||||
INLINE_MEDIA_SIZES,
|
INLINE_MEDIA_SIZES,
|
||||||
DEFAULT_INLINE_MEDIA_SIZE,
|
DEFAULT_INLINE_MEDIA_SIZE,
|
||||||
sanitizeInlineMediaSize,
|
sanitizeInlineMediaSize,
|
||||||
} from '../constants/inlineExerciseMedia'
|
} from '../constants/inlineExerciseMedia'
|
||||||
|
import { sanitizeInlineMediaCaption } from '../utils/inlineMediaCaption'
|
||||||
|
|
||||||
/** MIME/Dateiname → Übungs-media_type */
|
/** MIME/Dateiname → Übungs-media_type */
|
||||||
function inferExerciseMediaType(file) {
|
function inferExerciseMediaType(file) {
|
||||||
|
|
@ -28,14 +30,16 @@ function inferExerciseMediaType(file) {
|
||||||
* open: boolean,
|
* open: boolean,
|
||||||
* onClose: () => void,
|
* onClose: () => void,
|
||||||
* exerciseId: number,
|
* exerciseId: number,
|
||||||
|
* linkedExerciseMedia?: object[],
|
||||||
* onMediaListChanged: () => Promise<void>,
|
* onMediaListChanged: () => Promise<void>,
|
||||||
* onInserted: (exerciseMediaId: number, displaySize: string) => void,
|
* onInserted: (exerciseMediaId: number, displaySize: string, caption?: string) => void,
|
||||||
* }} props
|
* }} props
|
||||||
*/
|
*/
|
||||||
export default function ExerciseInlineFileMediaModal({
|
export default function ExerciseInlineFileMediaModal({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
exerciseId,
|
exerciseId,
|
||||||
|
linkedExerciseMedia = [],
|
||||||
onMediaListChanged,
|
onMediaListChanged,
|
||||||
onInserted,
|
onInserted,
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -49,6 +53,16 @@ export default function ExerciseInlineFileMediaModal({
|
||||||
const [uploadFile, setUploadFile] = useState(null)
|
const [uploadFile, setUploadFile] = useState(null)
|
||||||
const [uploadTitle, setUploadTitle] = useState('')
|
const [uploadTitle, setUploadTitle] = useState('')
|
||||||
const [displaySize, setDisplaySize] = useState(DEFAULT_INLINE_MEDIA_SIZE)
|
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 () => {
|
const loadAssets = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -74,6 +88,7 @@ export default function ExerciseInlineFileMediaModal({
|
||||||
setSelectedAssetId(null)
|
setSelectedAssetId(null)
|
||||||
setUploadFile(null)
|
setUploadFile(null)
|
||||||
setUploadTitle('')
|
setUploadTitle('')
|
||||||
|
setUploadInputKey((k) => k + 1)
|
||||||
setDisplaySize(DEFAULT_INLINE_MEDIA_SIZE)
|
setDisplaySize(DEFAULT_INLINE_MEDIA_SIZE)
|
||||||
setErr(null)
|
setErr(null)
|
||||||
const t = setTimeout(loadAssets, 280)
|
const t = setTimeout(loadAssets, 280)
|
||||||
|
|
@ -92,6 +107,17 @@ export default function ExerciseInlineFileMediaModal({
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const size = sanitizeInlineMediaSize(displaySize)
|
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)
|
setBusy(true)
|
||||||
setErr(null)
|
setErr(null)
|
||||||
try {
|
try {
|
||||||
|
|
@ -107,12 +133,15 @@ export default function ExerciseInlineFileMediaModal({
|
||||||
throw new Error('Antwort ohne exercise_media-ID')
|
throw new Error('Antwort ohne exercise_media-ID')
|
||||||
}
|
}
|
||||||
await onMediaListChanged()
|
await onMediaListChanged()
|
||||||
onInserted(Number(mid), size)
|
onInserted(
|
||||||
|
Number(mid),
|
||||||
|
size,
|
||||||
|
sanitizeInlineMediaCaption(assetMeta?.original_filename || ''),
|
||||||
|
)
|
||||||
onClose()
|
onClose()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e.message || String(e)
|
const msg = e.message || String(e)
|
||||||
setErr(msg)
|
setErr(msg)
|
||||||
alert(msg)
|
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
|
|
@ -141,9 +170,13 @@ export default function ExerciseInlineFileMediaModal({
|
||||||
throw new Error('Antwort ohne exercise_media-ID')
|
throw new Error('Antwort ohne exercise_media-ID')
|
||||||
}
|
}
|
||||||
await onMediaListChanged()
|
await onMediaListChanged()
|
||||||
onInserted(Number(mid), size)
|
const cap = sanitizeInlineMediaCaption(
|
||||||
|
uploadTitle.trim() || uploadFile.name || '',
|
||||||
|
)
|
||||||
|
onInserted(Number(mid), size, cap)
|
||||||
setUploadFile(null)
|
setUploadFile(null)
|
||||||
setUploadTitle('')
|
setUploadTitle('')
|
||||||
|
setUploadInputKey((k) => k + 1)
|
||||||
onClose()
|
onClose()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.code === 'MEDIA_ASSET_IN_TRASH' && e.payload?.media_asset_id != null) {
|
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
|
if (!open) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -208,6 +243,9 @@ export default function ExerciseInlineFileMediaModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ overflowY: 'auto', flex: 1, padding: '12px 14px' }}>
|
<div style={{ overflowY: 'auto', flex: 1, padding: '12px 14px' }}>
|
||||||
|
{err && !loading ? (
|
||||||
|
<p style={{ color: 'var(--danger)', marginTop: '0', marginBottom: '12px', fontSize: '0.9rem' }}>{err}</p>
|
||||||
|
) : null}
|
||||||
{tab === 'library' && (
|
{tab === 'library' && (
|
||||||
<>
|
<>
|
||||||
<label className="form-label">Suche in der Bibliothek</label>
|
<label className="form-label">Suche in der Bibliothek</label>
|
||||||
|
|
@ -219,14 +257,15 @@ export default function ExerciseInlineFileMediaModal({
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
{loading ? <p style={{ color: 'var(--text3)', marginTop: '12px' }}>Laden…</p> : null}
|
{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' }}>
|
<div className="rte-inline-asset-grid" style={{ marginTop: '14px' }}>
|
||||||
{items.map((it) => {
|
{items.map((it) => {
|
||||||
const id = it.id
|
const id = it.id
|
||||||
const selected = selectedAssetId === id
|
const selected = selectedAssetId === id
|
||||||
const label = it.original_filename || it.copyright_notice || `Archiv #${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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={id}
|
key={id}
|
||||||
|
|
@ -235,6 +274,18 @@ export default function ExerciseInlineFileMediaModal({
|
||||||
onClick={() => setSelectedAssetId(id)}
|
onClick={() => setSelectedAssetId(id)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
>
|
>
|
||||||
|
<div className="rte-inline-asset-tile__thumb" aria-hidden>
|
||||||
|
{isImg && src ? (
|
||||||
|
<img alt="" src={src} className="rte-inline-asset-tile__thumb-img" />
|
||||||
|
) : isVid ? (
|
||||||
|
<span className="rte-inline-asset-tile__thumb-fallback">▶ Video</span>
|
||||||
|
) : (
|
||||||
|
<span className="rte-inline-asset-tile__thumb-fallback">PDF / Datei</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{linked ? (
|
||||||
|
<span className="rte-inline-asset-tile__badge">Bereits verknüpft</span>
|
||||||
|
) : null}
|
||||||
<span className="rte-inline-asset-tile__meta">
|
<span className="rte-inline-asset-tile__meta">
|
||||||
{(it.mime_type || '').split('/')[0] || 'datei'}
|
{(it.mime_type || '').split('/')[0] || 'datei'}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -252,20 +303,26 @@ export default function ExerciseInlineFileMediaModal({
|
||||||
{tab === 'upload' && (
|
{tab === 'upload' && (
|
||||||
<>
|
<>
|
||||||
<label className="form-label">Datei</label>
|
<label className="form-label">Datei</label>
|
||||||
|
<div className="rte-inline-file-row">
|
||||||
<input
|
<input
|
||||||
|
key={uploadInputKey}
|
||||||
|
id="rte-inline-file-upload-input"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*,video/*,application/pdf"
|
accept="image/*,video/*,application/pdf"
|
||||||
className="form-input"
|
className="rte-inline-file-input-hidden"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const f = e.target.files?.[0] || null
|
const f = e.target.files?.[0] || null
|
||||||
setUploadFile(f)
|
setUploadFile(f)
|
||||||
e.target.value = ''
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{uploadFile ? (
|
<label htmlFor="rte-inline-file-upload-input" className="btn btn-secondary rte-inline-file-pick-btn">
|
||||||
<p style={{ fontSize: '13px', color: 'var(--text2)', marginTop: '8px' }}>{uploadFile.name}</p>
|
Datei auswählen
|
||||||
) : null}
|
</label>
|
||||||
|
<span className="rte-inline-file-name" title={uploadFile?.name || ''}>
|
||||||
|
{uploadFile ? uploadFile.name : 'Keine Datei ausgewählt'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<label className="form-label" style={{ marginTop: '12px' }}>
|
<label className="form-label" style={{ marginTop: '12px' }}>
|
||||||
Titel (optional)
|
Titel (optional)
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -293,8 +350,13 @@ export default function ExerciseInlineFileMediaModal({
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{tab === 'library' ? (
|
{tab === 'library' ? (
|
||||||
<button type="button" className="btn btn-primary btn-full" disabled={busy || !selectedAssetId} onClick={handleLinkSelected}>
|
<button
|
||||||
{busy ? 'Verknüpfen…' : 'Verknüpfen & in Text einfügen'}
|
type="button"
|
||||||
|
className="btn btn-primary btn-full"
|
||||||
|
disabled={busy || !selectedAssetId}
|
||||||
|
onClick={handleLinkSelected}
|
||||||
|
>
|
||||||
|
{busy ? 'Bitte warten…' : selectedLinked ? 'In Text einfügen (bereits verknüpft)' : 'Verknüpfen & in Text einfügen'}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button type="button" className="btn btn-primary btn-full" disabled={busy || !uploadFile} onClick={handleUploadAndInsert}>
|
<button type="button" className="btn btn-primary btn-full" disabled={busy || !uploadFile} onClick={handleUploadAndInsert}>
|
||||||
|
|
|
||||||
68
frontend/src/components/ExerciseMediaThumbTile.jsx
Normal file
68
frontend/src/components/ExerciseMediaThumbTile.jsx
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
/**
|
||||||
|
* Kachelvorschau: Video nutzt ersten Frame (metadata), Bild = img, Embed = Label.
|
||||||
|
*/
|
||||||
|
import React from 'react'
|
||||||
|
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
|
||||||
|
|
||||||
|
export default function ExerciseMediaThumbTile({ exerciseId, media, onOpenPreview, size = 72 }) {
|
||||||
|
const src = !media.embed_url ? resolveExerciseMediaFileUrl(exerciseId, media) : null
|
||||||
|
const commonStyle = {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
title="Vorschau"
|
||||||
|
onClick={() => onOpenPreview(media)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
onOpenPreview(media)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
background: 'var(--surface2, rgba(127,127,127,0.12))',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{media.embed_url ? (
|
||||||
|
<span style={{ fontSize: '11px', padding: '4px', color: 'var(--text2)', textAlign: 'center' }}>
|
||||||
|
{media.embed_platform || 'Embed'}
|
||||||
|
</span>
|
||||||
|
) : (media.mime_type?.startsWith('image/') || media.media_type === 'image') && src ? (
|
||||||
|
<img alt="" src={src} style={commonStyle} />
|
||||||
|
) : (media.mime_type?.startsWith('video/') || media.media_type === 'video') && src ? (
|
||||||
|
<video
|
||||||
|
src={src}
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
style={{ ...commonStyle, pointerEvents: 'none' }}
|
||||||
|
onLoadedMetadata={(e) => {
|
||||||
|
try {
|
||||||
|
const el = e.currentTarget
|
||||||
|
const d = el.duration
|
||||||
|
el.currentTime = Number.isFinite(d) && d > 0 ? Math.min(0.05, d * 0.01) : 0.05
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: '11px', color: 'var(--text2)' }}>Datei</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import React, { useRef, useEffect, useState, useCallback } from 'react'
|
import React, { useRef, useEffect, useState, useCallback } from 'react'
|
||||||
import ExerciseInlineFileMediaModal from './ExerciseInlineFileMediaModal'
|
import ExerciseInlineFileMediaModal from './ExerciseInlineFileMediaModal'
|
||||||
import ExerciseInlineEmbedModal from './ExerciseInlineEmbedModal'
|
import ExerciseInlineEmbedModal from './ExerciseInlineEmbedModal'
|
||||||
|
import { sanitizeInlineMediaCaption } from '../utils/inlineMediaCaption'
|
||||||
|
import {
|
||||||
|
SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
|
||||||
|
parseExerciseMediaDragPayload,
|
||||||
|
} from '../utils/exerciseInlineMediaRefs'
|
||||||
|
|
||||||
function exec(cmd, value = null) {
|
function exec(cmd, value = null) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -47,16 +52,48 @@ function normalText() {
|
||||||
formatBlock('p')
|
formatBlock('p')
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildInlineExerciseMediaHtml(mediaId, displaySize = 'medium') {
|
function escapeHtmlAttr(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInlineExerciseMediaHtml(mediaId, displaySize = 'medium', caption = '') {
|
||||||
const sid = parseInt(String(mediaId), 10)
|
const sid = parseInt(String(mediaId), 10)
|
||||||
if (!Number.isFinite(sid) || sid < 1) return null
|
if (!Number.isFinite(sid) || sid < 1) return null
|
||||||
const sz = ['small', 'medium', 'full'].includes(displaySize) ? displaySize : 'medium'
|
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>`
|
const cap = sanitizeInlineMediaCaption(caption)
|
||||||
|
let attrs = `data-shinkan-exercise-media="${sid}" data-shinkan-exercise-media-size="${sz}" class="shinkan-inline-media"`
|
||||||
|
if (cap) attrs += ` data-shinkan-exercise-media-caption="${escapeHtmlAttr(cap)}"`
|
||||||
|
return `<span ${attrs}>\u2060</span>`
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertExerciseMediaPlaceholder(editorEl, mediaId, displaySize = 'medium') {
|
function patchExitGlyphAfterPlaceholder(editorEl, mediaId, sel) {
|
||||||
|
const sid = String(parseInt(String(mediaId), 10))
|
||||||
|
const hits = editorEl.querySelectorAll(`span.shinkan-inline-media[data-shinkan-exercise-media="${sid}"]`)
|
||||||
|
const span = hits[hits.length - 1]
|
||||||
|
if (!span?.parentNode || !sel) return
|
||||||
|
let n = span.nextSibling
|
||||||
|
if (!n || n.nodeType !== Node.TEXT_NODE || !n.textContent.includes('\u200B')) {
|
||||||
|
const zn = document.createTextNode('\u200B')
|
||||||
|
span.parentNode.insertBefore(zn, span.nextSibling)
|
||||||
|
n = zn
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = document.createRange()
|
||||||
|
r.setStart(n, n.textContent.length)
|
||||||
|
r.collapse(true)
|
||||||
|
sel.removeAllRanges()
|
||||||
|
sel.addRange(r)
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertExerciseMediaPlaceholder(editorEl, mediaId, displaySize = 'medium', caption = '') {
|
||||||
if (!editorEl || mediaId == null) return false
|
if (!editorEl || mediaId == null) return false
|
||||||
const html = buildInlineExerciseMediaHtml(mediaId, displaySize)
|
const html = buildInlineExerciseMediaHtml(mediaId, displaySize, caption)
|
||||||
if (!html) return false
|
if (!html) return false
|
||||||
|
|
||||||
editorEl.focus()
|
editorEl.focus()
|
||||||
|
|
@ -90,7 +127,10 @@ function insertExerciseMediaPlaceholder(editorEl, mediaId, displaySize = 'medium
|
||||||
} catch {
|
} catch {
|
||||||
inserted = false
|
inserted = false
|
||||||
}
|
}
|
||||||
if (inserted) return true
|
if (inserted) {
|
||||||
|
patchExitGlyphAfterPlaceholder(editorEl, mediaId, sel)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
let range = null
|
let range = null
|
||||||
try {
|
try {
|
||||||
|
|
@ -112,7 +152,9 @@ function insertExerciseMediaPlaceholder(editorEl, mediaId, displaySize = 'medium
|
||||||
if (!span) return false
|
if (!span) return false
|
||||||
range.deleteContents()
|
range.deleteContents()
|
||||||
range.insertNode(span)
|
range.insertNode(span)
|
||||||
range.setStartAfter(span)
|
const zn = document.createTextNode('\u200B')
|
||||||
|
span.parentNode.insertBefore(zn, span.nextSibling)
|
||||||
|
range.setStart(zn, zn.textContent.length)
|
||||||
range.collapse(true)
|
range.collapse(true)
|
||||||
sel.removeAllRanges()
|
sel.removeAllRanges()
|
||||||
sel.addRange(range)
|
sel.addRange(range)
|
||||||
|
|
@ -124,7 +166,9 @@ function insertExerciseMediaPlaceholder(editorEl, mediaId, displaySize = 'medium
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Leichter WYSIWYG (contenteditable).
|
* Leichter WYSIWYG (contenteditable).
|
||||||
* @param {{ inlineExerciseId?: number|null, onExerciseMediaListChanged?: () => Promise<void> }} [extra]
|
* @param {{
|
||||||
|
* linkedExerciseMedia?: object[],
|
||||||
|
* }} [extra]
|
||||||
*/
|
*/
|
||||||
export default function RichTextEditor({
|
export default function RichTextEditor({
|
||||||
value,
|
value,
|
||||||
|
|
@ -132,6 +176,7 @@ export default function RichTextEditor({
|
||||||
placeholder,
|
placeholder,
|
||||||
minHeight = '140px',
|
minHeight = '140px',
|
||||||
inlineExerciseId = null,
|
inlineExerciseId = null,
|
||||||
|
linkedExerciseMedia = [],
|
||||||
onExerciseMediaListChanged,
|
onExerciseMediaListChanged,
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef(null)
|
const ref = useRef(null)
|
||||||
|
|
@ -140,6 +185,8 @@ export default function RichTextEditor({
|
||||||
const [fileModalOpen, setFileModalOpen] = useState(false)
|
const [fileModalOpen, setFileModalOpen] = useState(false)
|
||||||
const [embedModalOpen, setEmbedModalOpen] = useState(false)
|
const [embedModalOpen, setEmbedModalOpen] = useState(false)
|
||||||
|
|
||||||
|
const showInlineToolbar = inlineExerciseId != null && Number(inlineExerciseId) > 0
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = ref.current
|
const el = ref.current
|
||||||
if (!el || focused) return
|
if (!el || focused) return
|
||||||
|
|
@ -169,13 +216,13 @@ export default function RichTextEditor({
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const finalizeInsertFromModal = useCallback(
|
const finalizeInsertFromModal = useCallback(
|
||||||
(mediaId, displaySize) => {
|
(mediaId, displaySize, caption) => {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
const shell = ref.current
|
const shell = ref.current
|
||||||
if (!shell) return
|
if (!shell) return
|
||||||
shell.focus()
|
shell.focus()
|
||||||
restoreSelection(pendingRangeRef.current)
|
restoreSelection(pendingRangeRef.current)
|
||||||
const ok = insertExerciseMediaPlaceholder(shell, mediaId, displaySize)
|
const ok = insertExerciseMediaPlaceholder(shell, mediaId, displaySize, caption)
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
alert(
|
alert(
|
||||||
'Einfügen ist fehlgeschlagen — bitte Cursor ins Textfeld setzen und den Schalter erneut verwenden.',
|
'Einfügen ist fehlgeschlagen — bitte Cursor ins Textfeld setzen und den Schalter erneut verwenden.',
|
||||||
|
|
@ -189,6 +236,93 @@ export default function RichTextEditor({
|
||||||
[sync],
|
[sync],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const onEditorKeyDown = useCallback(
|
||||||
|
(e) => {
|
||||||
|
const el = ref.current
|
||||||
|
if (!el || e.key !== 'Enter') return
|
||||||
|
const sel = window.getSelection()
|
||||||
|
if (!sel || sel.rangeCount === 0 || !el.contains(sel.focusNode)) return
|
||||||
|
let node = sel.focusNode
|
||||||
|
let elNode = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement
|
||||||
|
const host = elNode?.closest?.('.shinkan-inline-media')
|
||||||
|
if (!host || !el.contains(host)) return
|
||||||
|
e.preventDefault()
|
||||||
|
patchExitGlyphAfterPlaceholder(el, host.getAttribute('data-shinkan-exercise-media'), sel)
|
||||||
|
try {
|
||||||
|
exec('insertParagraph')
|
||||||
|
} catch {
|
||||||
|
exec('insertLineBreak')
|
||||||
|
}
|
||||||
|
sync()
|
||||||
|
},
|
||||||
|
[sync],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onEditorDragOver = useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (!showInlineToolbar) return
|
||||||
|
const types = e.dataTransfer?.types ? Array.from(e.dataTransfer.types) : []
|
||||||
|
if (types.includes(SHINKAN_EXERCISE_MEDIA_DRAG_MIME)) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.dataTransfer.dropEffect = 'copy'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[showInlineToolbar],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onEditorDrop = useCallback(
|
||||||
|
(e) => {
|
||||||
|
const el = ref.current
|
||||||
|
if (!el || !showInlineToolbar) return
|
||||||
|
const raw = e.dataTransfer?.getData(SHINKAN_EXERCISE_MEDIA_DRAG_MIME)
|
||||||
|
const parsed = parseExerciseMediaDragPayload(raw)
|
||||||
|
if (!parsed) return
|
||||||
|
e.preventDefault()
|
||||||
|
el.focus()
|
||||||
|
const sel = window.getSelection()
|
||||||
|
if (!sel) return
|
||||||
|
let r = null
|
||||||
|
try {
|
||||||
|
if (document.caretRangeFromPoint) {
|
||||||
|
r = document.caretRangeFromPoint(e.clientX, e.clientY)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
r = null
|
||||||
|
}
|
||||||
|
if (!r) {
|
||||||
|
try {
|
||||||
|
const p = document.caretPositionFromPoint?.(e.clientX, e.clientY)
|
||||||
|
if (p?.offsetNode) {
|
||||||
|
const nr = document.createRange()
|
||||||
|
nr.setStart(p.offsetNode, p.offset)
|
||||||
|
nr.collapse(true)
|
||||||
|
r = nr
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
r = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (r && el.contains(r.commonAncestorContainer)) {
|
||||||
|
sel.removeAllRanges()
|
||||||
|
sel.addRange(r)
|
||||||
|
} else {
|
||||||
|
const anchor = document.createRange()
|
||||||
|
try {
|
||||||
|
anchor.selectNodeContents(el)
|
||||||
|
anchor.collapse(false)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sel.removeAllRanges()
|
||||||
|
sel.addRange(anchor)
|
||||||
|
}
|
||||||
|
insertExerciseMediaPlaceholder(el, parsed.exerciseMediaId, 'medium', parsed.caption)
|
||||||
|
sync()
|
||||||
|
el.focus()
|
||||||
|
},
|
||||||
|
[sync, showInlineToolbar],
|
||||||
|
)
|
||||||
|
|
||||||
const run = (fn) => (e) => {
|
const run = (fn) => (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
@ -221,8 +355,6 @@ export default function RichTextEditor({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const showInlineToolbar = inlineExerciseId != null && Number(inlineExerciseId) > 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rich-text-editor-wrap">
|
<div className="rich-text-editor-wrap">
|
||||||
<div className="rich-text-toolbar" role="toolbar" aria-label="Formatierung">
|
<div className="rich-text-toolbar" role="toolbar" aria-label="Formatierung">
|
||||||
|
|
@ -303,6 +435,10 @@ export default function RichTextEditor({
|
||||||
sync()
|
sync()
|
||||||
}}
|
}}
|
||||||
onInput={sync}
|
onInput={sync}
|
||||||
|
onKeyDown={onEditorKeyDown}
|
||||||
|
onDragEnter={onEditorDragOver}
|
||||||
|
onDragOver={onEditorDragOver}
|
||||||
|
onDrop={onEditorDrop}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showInlineToolbar ? (
|
{showInlineToolbar ? (
|
||||||
|
|
@ -310,8 +446,9 @@ export default function RichTextEditor({
|
||||||
open={fileModalOpen}
|
open={fileModalOpen}
|
||||||
onClose={() => setFileModalOpen(false)}
|
onClose={() => setFileModalOpen(false)}
|
||||||
exerciseId={Number(inlineExerciseId)}
|
exerciseId={Number(inlineExerciseId)}
|
||||||
|
linkedExerciseMedia={linkedExerciseMedia}
|
||||||
onMediaListChanged={refreshExerciseMedia}
|
onMediaListChanged={refreshExerciseMedia}
|
||||||
onInserted={(mid, sz) => finalizeInsertFromModal(mid, sz)}
|
onInserted={(mid, sz, cap) => finalizeInsertFromModal(mid, sz, cap)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{showInlineToolbar ? (
|
{showInlineToolbar ? (
|
||||||
|
|
@ -320,7 +457,7 @@ export default function RichTextEditor({
|
||||||
onClose={() => setEmbedModalOpen(false)}
|
onClose={() => setEmbedModalOpen(false)}
|
||||||
exerciseId={Number(inlineExerciseId)}
|
exerciseId={Number(inlineExerciseId)}
|
||||||
onMediaListChanged={refreshExerciseMedia}
|
onMediaListChanged={refreshExerciseMedia}
|
||||||
onInserted={(mid, sz) => finalizeInsertFromModal(mid, sz)}
|
onInserted={(mid, sz, cap) => finalizeInsertFromModal(mid, sz, cap)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
|
import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
|
||||||
import ExerciseMediaEmbed from '../components/ExerciseMediaEmbed'
|
import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaStrip'
|
||||||
import { formatSkillLevelSlug } from '../constants/skillLevels'
|
import { formatSkillLevelSlug } from '../constants/skillLevels'
|
||||||
|
|
||||||
function TagRow({ exercise }) {
|
function TagRow({ exercise }) {
|
||||||
|
|
@ -104,10 +104,6 @@ function ExerciseDetailPage() {
|
||||||
if (!exercise) return null
|
if (!exercise) return null
|
||||||
|
|
||||||
const meta = metaParts(exercise)
|
const meta = metaParts(exercise)
|
||||||
const visibleMedia = (exercise.media || []).filter((m) => {
|
|
||||||
const lc = String(m.asset_lifecycle_state || 'active').toLowerCase()
|
|
||||||
return lc !== 'trash_hidden'
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="exercise-detail-shell" style={{ padding: '12px 12px 24px' }}>
|
<div className="exercise-detail-shell" style={{ padding: '12px 12px 24px' }}>
|
||||||
|
|
@ -168,23 +164,7 @@ function ExerciseDetailPage() {
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{visibleMedia.length > 0 && (
|
<ExerciseAttachmentMediaStrip exercise={exercise} exerciseId={exercise.id} />
|
||||||
<section className="card exercise-detail-section">
|
|
||||||
<h2>Medien</h2>
|
|
||||||
{visibleMedia.map((m) => (
|
|
||||||
<div key={m.id} style={{ marginBottom: '1.25rem' }}>
|
|
||||||
<strong style={{ fontSize: '15px' }}>{m.title || m.original_filename || m.media_type}</strong>
|
|
||||||
{String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_soft' && (
|
|
||||||
<p style={{ fontSize: '12px', color: 'var(--danger)', margin: '6px 0 0' }}>
|
|
||||||
Hinweis: Dieses Medium ist im Papierkorb und steht künftig nicht mehr zur Verfügung.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{m.description && <p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{m.description}</p>}
|
|
||||||
<ExerciseMediaEmbed media={m} exerciseId={exercise.id} layoutSize="full" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{exercise.trainer_notes && (
|
{exercise.trainer_notes && (
|
||||||
<section className="card exercise-detail-section">
|
<section className="card exercise-detail-section">
|
||||||
|
|
|
||||||
|
|
@ -4,87 +4,14 @@ import api, { buildExerciseApiPayload } from '../utils/api'
|
||||||
import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
|
import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
|
||||||
import RichTextEditor from '../components/RichTextEditor'
|
import RichTextEditor from '../components/RichTextEditor'
|
||||||
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
|
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
|
||||||
|
import ExerciseMediaThumbTile from '../components/ExerciseMediaThumbTile'
|
||||||
|
import {
|
||||||
|
SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
|
||||||
|
buildExerciseMediaDragPayload,
|
||||||
|
} from '../utils/exerciseInlineMediaRefs'
|
||||||
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
|
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
/** MIME/Dateiname → Übungs-media_type; null → Dropdown-Fallback. */
|
|
||||||
function inferExerciseMediaType(file) {
|
|
||||||
if (!file) return null
|
|
||||||
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 null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Kachelvorschau: Video nutzt ersten Frame (metadata), Bild = img. */
|
|
||||||
function ExerciseMediaThumbTile({ exerciseId, media, onOpenPreview }) {
|
|
||||||
const src = !media.embed_url ? resolveExerciseMediaFileUrl(exerciseId, media) : null
|
|
||||||
const commonStyle = {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
objectFit: 'cover',
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
title="Vorschau"
|
|
||||||
onClick={() => onOpenPreview(media)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault()
|
|
||||||
onOpenPreview(media)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
width: 72,
|
|
||||||
height: 72,
|
|
||||||
flexShrink: 0,
|
|
||||||
borderRadius: '8px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
background: 'var(--surface2, rgba(127,127,127,0.12))',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{media.embed_url ? (
|
|
||||||
<span style={{ fontSize: '11px', padding: '4px', color: 'var(--text2)', textAlign: 'center' }}>
|
|
||||||
{media.embed_platform || 'Embed'}
|
|
||||||
</span>
|
|
||||||
) : (media.mime_type?.startsWith('image/') || media.media_type === 'image') && src ? (
|
|
||||||
<img alt="" src={src} style={commonStyle} />
|
|
||||||
) : (media.mime_type?.startsWith('video/') || media.media_type === 'video') && src ? (
|
|
||||||
<video
|
|
||||||
src={src}
|
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
preload="metadata"
|
|
||||||
style={{ ...commonStyle, pointerEvents: 'none' }}
|
|
||||||
onLoadedMetadata={(e) => {
|
|
||||||
try {
|
|
||||||
const el = e.currentTarget
|
|
||||||
const d = el.duration
|
|
||||||
el.currentTime = Number.isFinite(d) && d > 0 ? Math.min(0.05, d * 0.01) : 0.05
|
|
||||||
} catch (_) {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span style={{ fontSize: '11px', color: 'var(--text2)' }}>Datei</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const INTENSITY_OPTIONS = [
|
const INTENSITY_OPTIONS = [
|
||||||
{ value: '', label: '—' },
|
{ value: '', label: '—' },
|
||||||
{ value: 'niedrig', label: 'niedrig' },
|
{ value: 'niedrig', label: 'niedrig' },
|
||||||
|
|
@ -175,6 +102,7 @@ function ExerciseVariantFields({
|
||||||
prerequisiteOthers,
|
prerequisiteOthers,
|
||||||
rteMinHeight = '110px',
|
rteMinHeight = '110px',
|
||||||
inlineExerciseId,
|
inlineExerciseId,
|
||||||
|
linkedExerciseMedia = [],
|
||||||
onExerciseMediaListChanged,
|
onExerciseMediaListChanged,
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -206,6 +134,7 @@ function ExerciseVariantFields({
|
||||||
placeholder="Was unterscheidet diese Variante? (Listen über Symbolleiste)"
|
placeholder="Was unterscheidet diese Variante? (Listen über Symbolleiste)"
|
||||||
minHeight={rteMinHeight}
|
minHeight={rteMinHeight}
|
||||||
inlineExerciseId={inlineExerciseId}
|
inlineExerciseId={inlineExerciseId}
|
||||||
|
linkedExerciseMedia={linkedExerciseMedia}
|
||||||
onExerciseMediaListChanged={onExerciseMediaListChanged}
|
onExerciseMediaListChanged={onExerciseMediaListChanged}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -452,12 +381,6 @@ function ExerciseFormPage() {
|
||||||
const [variantEditSelection, setVariantEditSelection] = useState(null)
|
const [variantEditSelection, setVariantEditSelection] = useState(null)
|
||||||
const variantsDetailsRef = useRef(null)
|
const variantsDetailsRef = useRef(null)
|
||||||
|
|
||||||
const [mediaFiles, setMediaFiles] = useState([])
|
|
||||||
const [mediaType, setMediaType] = useState('image')
|
|
||||||
const [mediaTitle, setMediaTitle] = useState('')
|
|
||||||
const [mediaContext, setMediaContext] = useState('ablauf')
|
|
||||||
const [embedUrl, setEmbedUrl] = useState('')
|
|
||||||
const [embedTitle, setEmbedTitle] = useState('')
|
|
||||||
const [mediaFields, setMediaFields] = useState({})
|
const [mediaFields, setMediaFields] = useState({})
|
||||||
const [mediaSavingId, setMediaSavingId] = useState(null)
|
const [mediaSavingId, setMediaSavingId] = useState(null)
|
||||||
const [archiveOpen, setArchiveOpen] = useState(false)
|
const [archiveOpen, setArchiveOpen] = useState(false)
|
||||||
|
|
@ -763,88 +686,6 @@ function ExerciseFormPage() {
|
||||||
[mediaList],
|
[mediaList],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleUploadFile = async () => {
|
|
||||||
if (!exerciseId || mediaFiles.length === 0) {
|
|
||||||
alert('Datei(en) wählen')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const files = [...mediaFiles]
|
|
||||||
for (const file of files) {
|
|
||||||
const inferred = inferExerciseMediaType(file) || mediaType
|
|
||||||
const fd = new FormData()
|
|
||||||
fd.append('file', file)
|
|
||||||
fd.append('media_type', inferred)
|
|
||||||
fd.append('title', mediaTitle)
|
|
||||||
fd.append('description', '')
|
|
||||||
fd.append('context', mediaContext)
|
|
||||||
fd.append('is_primary', 'false')
|
|
||||||
try {
|
|
||||||
await api.uploadExerciseMedia(exerciseId, fd)
|
|
||||||
} catch (err) {
|
|
||||||
if (err.code === 'MEDIA_ASSET_IN_TRASH' && err.payload?.media_asset_id != null) {
|
|
||||||
const aid = err.payload.media_asset_id
|
|
||||||
const nameHint = file?.name || err.payload.original_filename || 'diese Datei'
|
|
||||||
if (
|
|
||||||
confirm(
|
|
||||||
`Die hochgeladene Datei ist inhaltsgleich mit einem Archiv-Medium im Papierkorb (${nameHint}). ` +
|
|
||||||
'Soll dieses Medium wieder aktiviert und an die Übung gehängt werden? (Es wird kein zweites Exemplar auf der Platte angelegt.)',
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
await api.postMediaAssetLifecycle(aid, 'reactivate')
|
|
||||||
await api.attachExerciseMediaFromAsset(exerciseId, {
|
|
||||||
media_asset_id: aid,
|
|
||||||
title: mediaTitle || undefined,
|
|
||||||
description: '',
|
|
||||||
context: mediaContext,
|
|
||||||
is_primary: false,
|
|
||||||
})
|
|
||||||
} catch (e2) {
|
|
||||||
alert(e2.message || String(e2))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else if (err.code === 'MEDIA_ASSET_UNAVAILABLE') {
|
|
||||||
alert(
|
|
||||||
(err.message || 'Archiv-Konflikt') +
|
|
||||||
' Bitte wenden Sie sich an einen Administrator oder wählen Sie eine andere Datei.',
|
|
||||||
)
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
alert(`Upload (${file.name}): ${err.message || String(err)}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setMediaFiles([])
|
|
||||||
setMediaTitle('')
|
|
||||||
await refreshMedia()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddEmbed = async () => {
|
|
||||||
if (!exerciseId || !embedUrl.trim()) {
|
|
||||||
alert('Embed-URL eingeben')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const fd = new FormData()
|
|
||||||
fd.append('embed_url', embedUrl.trim())
|
|
||||||
fd.append('media_type', 'video')
|
|
||||||
fd.append('title', embedTitle)
|
|
||||||
fd.append('description', '')
|
|
||||||
fd.append('context', mediaContext)
|
|
||||||
fd.append('is_primary', 'false')
|
|
||||||
try {
|
|
||||||
await api.uploadExerciseMedia(exerciseId, fd)
|
|
||||||
setEmbedUrl('')
|
|
||||||
setEmbedTitle('')
|
|
||||||
await refreshMedia()
|
|
||||||
} catch (err) {
|
|
||||||
alert('Embed: ' + err.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteMedia = async (mid) => {
|
const handleDeleteMedia = async (mid) => {
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!confirm(
|
||||||
|
|
@ -1050,6 +891,7 @@ function ExerciseFormPage() {
|
||||||
placeholder="Kurzbeschreibung (optional)"
|
placeholder="Kurzbeschreibung (optional)"
|
||||||
minHeight="80px"
|
minHeight="80px"
|
||||||
inlineExerciseId={isEdit ? exerciseId : null}
|
inlineExerciseId={isEdit ? exerciseId : null}
|
||||||
|
linkedExerciseMedia={isEdit ? mediaList : []}
|
||||||
onExerciseMediaListChanged={refreshMedia}
|
onExerciseMediaListChanged={refreshMedia}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1062,6 +904,7 @@ function ExerciseFormPage() {
|
||||||
placeholder="Trainingsziel"
|
placeholder="Trainingsziel"
|
||||||
minHeight="120px"
|
minHeight="120px"
|
||||||
inlineExerciseId={isEdit ? exerciseId : null}
|
inlineExerciseId={isEdit ? exerciseId : null}
|
||||||
|
linkedExerciseMedia={isEdit ? mediaList : []}
|
||||||
onExerciseMediaListChanged={refreshMedia}
|
onExerciseMediaListChanged={refreshMedia}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1074,6 +917,7 @@ function ExerciseFormPage() {
|
||||||
placeholder="Ablauf Schritt für Schritt"
|
placeholder="Ablauf Schritt für Schritt"
|
||||||
minHeight="180px"
|
minHeight="180px"
|
||||||
inlineExerciseId={isEdit ? exerciseId : null}
|
inlineExerciseId={isEdit ? exerciseId : null}
|
||||||
|
linkedExerciseMedia={isEdit ? mediaList : []}
|
||||||
onExerciseMediaListChanged={refreshMedia}
|
onExerciseMediaListChanged={refreshMedia}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1086,6 +930,7 @@ function ExerciseFormPage() {
|
||||||
placeholder="Matten, Raum, …"
|
placeholder="Matten, Raum, …"
|
||||||
minHeight="100px"
|
minHeight="100px"
|
||||||
inlineExerciseId={isEdit ? exerciseId : null}
|
inlineExerciseId={isEdit ? exerciseId : null}
|
||||||
|
linkedExerciseMedia={isEdit ? mediaList : []}
|
||||||
onExerciseMediaListChanged={refreshMedia}
|
onExerciseMediaListChanged={refreshMedia}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1098,6 +943,7 @@ function ExerciseFormPage() {
|
||||||
placeholder="Sicherheit, Varianten-Hinweise, …"
|
placeholder="Sicherheit, Varianten-Hinweise, …"
|
||||||
minHeight="100px"
|
minHeight="100px"
|
||||||
inlineExerciseId={isEdit ? exerciseId : null}
|
inlineExerciseId={isEdit ? exerciseId : null}
|
||||||
|
linkedExerciseMedia={isEdit ? mediaList : []}
|
||||||
onExerciseMediaListChanged={refreshMedia}
|
onExerciseMediaListChanged={refreshMedia}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1394,6 +1240,7 @@ function ExerciseFormPage() {
|
||||||
prerequisiteOthers={variants}
|
prerequisiteOthers={variants}
|
||||||
rteMinHeight="110px"
|
rteMinHeight="110px"
|
||||||
inlineExerciseId={isEdit ? exerciseId : null}
|
inlineExerciseId={isEdit ? exerciseId : null}
|
||||||
|
linkedExerciseMedia={isEdit ? mediaList : []}
|
||||||
onExerciseMediaListChanged={refreshMedia}
|
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}>
|
||||||
|
|
@ -1462,6 +1309,7 @@ function ExerciseFormPage() {
|
||||||
prerequisiteOthers={variants.filter((o) => o.id !== selectedVariantForEdit.id)}
|
prerequisiteOthers={variants.filter((o) => o.id !== selectedVariantForEdit.id)}
|
||||||
rteMinHeight="110px"
|
rteMinHeight="110px"
|
||||||
inlineExerciseId={isEdit ? exerciseId : null}
|
inlineExerciseId={isEdit ? exerciseId : null}
|
||||||
|
linkedExerciseMedia={isEdit ? mediaList : []}
|
||||||
onExerciseMediaListChanged={refreshMedia}
|
onExerciseMediaListChanged={refreshMedia}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1491,8 +1339,13 @@ function ExerciseFormPage() {
|
||||||
{isEdit && (
|
{isEdit && (
|
||||||
<div className="card" style={{ marginTop: '16px' }}>
|
<div className="card" style={{ marginTop: '16px' }}>
|
||||||
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Medien</h2>
|
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Medien</h2>
|
||||||
<p style={{ color: 'var(--text2)', fontSize: '13px' }}>
|
<p style={{ color: 'var(--text2)', fontSize: '13px', marginBottom: '6px' }}>
|
||||||
Datei oder Embed (YouTube, Vimeo, Instagram, TikTok). Max. 10 pro Übung.
|
Neue Uploads oder Embeds über die Textfeld-Symbolleiste („Medien im Text“ / „Embed im Text“). Hier
|
||||||
|
verwaltest du Verknüpfungen — Kachel in ein Textfeld ziehen, um sie an der Cursorposition einzufügen
|
||||||
|
(mittlere Darstellung).
|
||||||
|
</p>
|
||||||
|
<p style={{ color: 'var(--text3)', fontSize: '12px', marginTop: 0 }}>
|
||||||
|
Max. 10 Medien pro Übung.
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -1510,150 +1363,75 @@ function ExerciseFormPage() {
|
||||||
Medienbibliothek
|
Medienbibliothek
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gap: '12px', marginTop: '12px' }}>
|
|
||||||
<div>
|
|
||||||
<label className="form-label">Dateien</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
accept="image/*,video/*,application/pdf"
|
|
||||||
onChange={(e) => {
|
|
||||||
setMediaFiles(Array.from(e.target.files || []))
|
|
||||||
e.target.value = ''
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{mediaFiles.length > 0 && (
|
|
||||||
<div style={{ fontSize: '0.875rem', color: 'var(--text2)', marginTop: '6px' }}>
|
|
||||||
{mediaFiles.length} Datei(en): {mediaFiles.map((f) => f.name).join(', ')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="form-row" style={{ marginTop: '8px' }}>
|
|
||||||
<select className="form-input" value={mediaType} onChange={(e) => setMediaType(e.target.value)}>
|
|
||||||
<option value="image">Typ-Fallback: Bild</option>
|
|
||||||
<option value="video">Typ-Fallback: Video</option>
|
|
||||||
<option value="document">Typ-Fallback: PDF</option>
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-input"
|
|
||||||
placeholder="Titel (optional)"
|
|
||||||
value={mediaTitle}
|
|
||||||
onChange={(e) => setMediaTitle(e.target.value)}
|
|
||||||
style={{ marginTop: '8px' }}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
className="form-input"
|
|
||||||
value={mediaContext}
|
|
||||||
onChange={(e) => setMediaContext(e.target.value)}
|
|
||||||
style={{ marginTop: '8px' }}
|
|
||||||
>
|
|
||||||
<option value="ablauf">Ablauf</option>
|
|
||||||
<option value="detail">Detail</option>
|
|
||||||
<option value="trainer_hint">Trainer-Hinweis</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button type="button" className="btn btn-secondary" style={{ marginTop: '8px' }} onClick={handleUploadFile}>
|
|
||||||
Hochladen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="form-label">Embed-URL</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
className="form-input"
|
|
||||||
placeholder="https://…"
|
|
||||||
value={embedUrl}
|
|
||||||
onChange={(e) => setEmbedUrl(e.target.value)}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-input"
|
|
||||||
placeholder="Titel (optional)"
|
|
||||||
value={embedTitle}
|
|
||||||
onChange={(e) => setEmbedTitle(e.target.value)}
|
|
||||||
style={{ marginTop: '8px' }}
|
|
||||||
/>
|
|
||||||
<button type="button" className="btn btn-secondary" style={{ marginTop: '8px' }} onClick={handleAddEmbed}>
|
|
||||||
Embed hinzufügen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{mediaList.length > 0 && (
|
{mediaList.length > 0 && (
|
||||||
<ul style={{ marginTop: '12px', paddingLeft: '0', listStyle: 'none' }}>
|
<ul className="exercise-edit-media-strip">
|
||||||
{mediaList.map((m, idx) => (
|
{mediaList.map((m, idx) => {
|
||||||
<li
|
const cap =
|
||||||
key={m.id}
|
|
||||||
className="card"
|
|
||||||
style={{
|
|
||||||
marginBottom: '10px',
|
|
||||||
padding: '10px 12px',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
display: 'flex',
|
|
||||||
gap: '12px',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ExerciseMediaThumbTile exerciseId={exerciseId} media={m} onOpenPreview={setMediaPreview} />
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
|
|
||||||
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
|
|
||||||
#{idx + 1} · {m.media_type}
|
|
||||||
{m.embed_platform ? ` · ${m.embed_platform}` : ''}
|
|
||||||
</span>
|
|
||||||
{mediaList.length > 1 && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
style={{ fontSize: '11px', padding: '2px 8px' }}
|
|
||||||
disabled={idx === 0}
|
|
||||||
onClick={() => moveMediaRow(idx, -1)}
|
|
||||||
title="Nach oben"
|
|
||||||
>
|
|
||||||
↑
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
style={{ fontSize: '11px', padding: '2px 8px' }}
|
|
||||||
disabled={idx >= mediaList.length - 1}
|
|
||||||
onClick={() => moveMediaRow(idx, 1)}
|
|
||||||
title="Nach unten"
|
|
||||||
>
|
|
||||||
↓
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
color: 'var(--text2)',
|
|
||||||
marginTop: '6px',
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
lineHeight: 1.35,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(m.original_filename || '').trim() ||
|
|
||||||
(m.title || '').trim() ||
|
(m.title || '').trim() ||
|
||||||
(m.embed_url ? m.embed_url : '') ||
|
(m.original_filename || '').trim() ||
|
||||||
'—'}
|
(m.embed_url ? String(m.embed_url).replace(/^https?:\/\//i, '').slice(0, 80) : '')
|
||||||
|
const sub = [m.media_type, m.embed_platform].filter(Boolean).join(' · ') || 'Medium'
|
||||||
|
const payloadCaption = (
|
||||||
|
[m.title, m.original_filename].find((x) => typeof x === 'string' && x.trim()) || ''
|
||||||
|
).trim()
|
||||||
|
return (
|
||||||
|
<li key={m.id} className="exercise-edit-media-strip__item">
|
||||||
|
<div className="exercise-edit-media-strip__lead">
|
||||||
|
{!m.embed_url ? (
|
||||||
|
<ExerciseMediaThumbTile exerciseId={exerciseId} media={m} onOpenPreview={setMediaPreview} size={76} />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="exercise-edit-media-strip__embed-badge exercise-edit-media-strip__embed-badge--solo"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{m.embed_platform || 'Embed'}
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row" style={{ marginTop: '8px', display: 'grid', gap: '8px' }}>
|
)}
|
||||||
|
<div
|
||||||
|
className="exercise-edit-media-strip__handle"
|
||||||
|
title="Mit Drag und Drop in ein Textfeld ziehen"
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => {
|
||||||
|
try {
|
||||||
|
e.dataTransfer.setData(
|
||||||
|
SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
|
||||||
|
buildExerciseMediaDragPayload(m.id, payloadCaption),
|
||||||
|
)
|
||||||
|
e.dataTransfer.effectAllowed = 'copy'
|
||||||
|
} catch (_) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⣿<span className="exercise-edit-media-strip__handle-text"> Ziehen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="exercise-edit-media-strip__body">
|
||||||
|
<div className="exercise-edit-media-strip__headline">
|
||||||
|
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
|
||||||
|
#{m.id} · {sub}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '13px', color: 'var(--text2)', lineHeight: 1.35 }}>{cap || '—'}</div>
|
||||||
|
<div className="exercise-edit-media-strip__toolbar">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-input"
|
className="form-input exercise-edit-media-strip__title"
|
||||||
placeholder="Titel"
|
placeholder="Titel"
|
||||||
value={(mediaFields[m.id] || {}).title ?? ''}
|
value={(mediaFields[m.id] || {}).title ?? ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setMediaFields((prev) => ({
|
setMediaFields((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[m.id]: { ...(prev[m.id] || {}), title: e.target.value, context: (prev[m.id] || {}).context || 'ablauf' },
|
[m.id]: {
|
||||||
|
...(prev[m.id] || {}),
|
||||||
|
title: e.target.value,
|
||||||
|
context: (prev[m.id] || {}).context || 'ablauf',
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
className="form-input"
|
className="form-input exercise-edit-media-strip__ctx"
|
||||||
value={(mediaFields[m.id] || {}).context || 'ablauf'}
|
value={(mediaFields[m.id] || {}).context || 'ablauf'}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setMediaFields((prev) => ({
|
setMediaFields((prev) => ({
|
||||||
|
|
@ -1670,31 +1448,54 @@ function ExerciseFormPage() {
|
||||||
<option value="detail">Detail</option>
|
<option value="detail">Detail</option>
|
||||||
<option value="trainer_hint">Trainer-Hinweis</option>
|
<option value="trainer_hint">Trainer-Hinweis</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary"
|
|
||||||
style={{ fontSize: '12px' }}
|
|
||||||
disabled={mediaSavingId === m.id}
|
|
||||||
onClick={() => saveMediaMeta(m.id)}
|
|
||||||
>
|
|
||||||
{mediaSavingId === m.id ? 'Speichern…' : 'Titel & Sektion speichern'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="exercise-edit-media-strip__actions">
|
||||||
|
{mediaList.length > 1 && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
style={{
|
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||||||
marginTop: '8px',
|
disabled={idx === 0}
|
||||||
fontSize: '12px',
|
onClick={() => moveMediaRow(idx, -1)}
|
||||||
padding: '6px 12px',
|
title="Nach oben"
|
||||||
}}
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||||||
|
disabled={idx >= mediaList.length - 1}
|
||||||
|
onClick={() => moveMediaRow(idx, 1)}
|
||||||
|
title="Nach unten"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ fontSize: '12px', padding: '4px 10px' }}
|
||||||
|
disabled={mediaSavingId === m.id}
|
||||||
|
onClick={() => saveMediaMeta(m.id)}
|
||||||
|
>
|
||||||
|
{mediaSavingId === m.id ? '…' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ fontSize: '12px', padding: '4px 10px' }}
|
||||||
onClick={() => handleDeleteMedia(m.id)}
|
onClick={() => handleDeleteMedia(m.id)}
|
||||||
>
|
>
|
||||||
Aus Übung entfernen
|
Entfernen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
{archiveOpen && (
|
{archiveOpen && (
|
||||||
|
|
|
||||||
85
frontend/src/utils/exerciseInlineMediaRefs.js
Normal file
85
frontend/src/utils/exerciseInlineMediaRefs.js
Normal file
|
|
@ -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<number>}
|
||||||
|
*/
|
||||||
|
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<number>}
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
* Restriktiver als sanitizeTrainerHtml: Allowlist für XSS-Minimierung.
|
* 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_BLOCK = new Set(['p', 'div', 'br', 'ul', 'ol', 'li', 'h3'])
|
||||||
const ALLOWED_INLINE = new Set(['b', 'strong', 'i', 'em', 'u', 'span', 'a'])
|
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)) {
|
if (sz && _SIZE_OK.has(sz)) {
|
||||||
out.setAttribute('data-shinkan-exercise-media-size', 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 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(' '))
|
||||||
|
|
|
||||||
17
frontend/src/utils/inlineMediaCaption.js
Normal file
17
frontend/src/utils/inlineMediaCaption.js
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user