feat(exercises): bump version to 0.8.64 and enhance media handling
All checks were successful
Deploy Development / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 23s
Test Suite / pytest-backend (pull_request) Successful in 23s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 7s
Test Suite / playwright-tests (pull_request) Successful in 23s

- Incremented application version to 0.8.64 and updated changelog with new features.
- Improved media handling in the Rich Text Editor with auto-scrolling during drag-and-drop.
- Added new CSS styles for video thumbnails and enhanced layout for media items.
- Removed deprecated `ExerciseAttachmentMediaStrip` from the ExerciseFullContent component.
- Updated ExerciseFormPage to manage form dirty state and prevent data loss on navigation.
This commit is contained in:
Lars 2026-05-08 12:35:28 +02:00
parent 337f29401b
commit 5cf775c920
8 changed files with 185 additions and 65 deletions

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.63"
APP_VERSION = "0.8.64"
BUILD_DATE = "2026-05-08"
DB_SCHEMA_VERSION = "20260508049"
@ -29,6 +29,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.64",
"date": "2026-05-08",
"changes": [
"Übung bearbeiten: Auto-Scroll beim Drag von Medien zu Textfeldern; Medien-Kacheln mehrspaltig; Sektion/Ablauf-Zuordnung an Medien entfernt (nur noch Titel bearbeiten); Picker: Video-Vorschau-Frame; Katalogvorschau ohne Anhangs-Medienliste; Ansehen mit Speichern-Hinweis + Zurück zur Bearbeitung von der Ansicht",
],
},
{
"version": "0.8.63",
"date": "2026-05-08",

View File

@ -4018,6 +4018,13 @@ a.analysis-split__nav-item {
height: 100%;
object-fit: cover;
}
.rte-inline-asset-tile__thumb-video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
pointer-events: none;
}
.rte-inline-asset-tile__thumb-fallback {
font-size: 11px;
color: var(--text3);
@ -4066,9 +4073,10 @@ a.analysis-split__nav-item {
list-style: none;
padding: 0;
margin: 14px 0 0;
display: flex;
flex-direction: column;
gap: 10px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 12px;
align-items: stretch;
}
.exercise-edit-media-strip__item {
display: flex;
@ -4129,15 +4137,10 @@ a.analysis-split__nav-item {
}
.exercise-edit-media-strip__toolbar {
display: grid;
grid-template-columns: 1fr minmax(120px, 160px);
grid-template-columns: 1fr;
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;

View File

@ -4,7 +4,6 @@
import React from 'react'
import { Link } from 'react-router-dom'
import ExerciseRichTextBlock from './ExerciseRichTextBlock'
import ExerciseAttachmentMediaStrip from './ExerciseAttachmentMediaStrip'
function TagRow({ exercise }) {
const tags = []
@ -118,7 +117,6 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
<ExerciseRichTextBlock html={exercise.execution} exerciseId={resolvedId} media={exercise.media} />
</section>
)}
<ExerciseAttachmentMediaStrip exercise={exercise} exerciseId={resolvedId} />
{exercise.trainer_notes && (
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>

View File

@ -11,6 +11,41 @@ import {
} from '../constants/inlineExerciseMedia'
import { sanitizeInlineMediaCaption } from '../utils/inlineMediaCaption'
function RtePickerAssetThumb({ asset }) {
const id = asset.id
const src = resolveMediaAssetFileUrl(id)
const mt = (asset.mime_type || '').toLowerCase()
if (mt.startsWith('image/') && src) {
return <img alt="" src={src} className="rte-inline-asset-tile__thumb-img" />
}
if (mt.startsWith('video/') && src) {
return (
<video
key={`v-${id}`}
className="rte-inline-asset-tile__thumb-video"
src={src}
muted
playsInline
preload="metadata"
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 */
}
}}
/>
)
}
const nameLow = String(asset.original_filename || '').toLowerCase()
if (mt.includes('pdf') || nameLow.endsWith('.pdf')) {
return <span className="rte-inline-asset-tile__thumb-fallback">PDF</span>
}
return <span className="rte-inline-asset-tile__thumb-fallback">Datei</span>
}
/** MIME/Dateiname → Übungs-media_type */
function inferExerciseMediaType(file) {
if (!file) return 'image'
@ -263,9 +298,6 @@ export default function ExerciseInlineFileMediaModal({
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 (
<button
key={id}
@ -275,13 +307,7 @@ export default function ExerciseInlineFileMediaModal({
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>
)}
<RtePickerAssetThumb asset={it} />
</div>
{linked ? (
<span className="rte-inline-asset-tile__badge">Bereits verknüpft</span>

View File

@ -6,6 +6,7 @@ import {
SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
parseExerciseMediaDragPayload,
} from '../utils/exerciseInlineMediaRefs'
import { autoScrollForDragNearEdges } from '../utils/dragAutoScroll'
function exec(cmd, value = null) {
try {
@ -265,6 +266,7 @@ export default function RichTextEditor({
if (types.includes(SHINKAN_EXERCISE_MEDIA_DRAG_MIME)) {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
autoScrollForDragNearEdges(e)
}
},
[showInlineToolbar],

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { Link, useNavigate, useParams, useLocation } from 'react-router-dom'
import api from '../utils/api'
import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaStrip'
@ -53,6 +53,7 @@ function metaParts(exercise) {
function ExerciseDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
const location = useLocation()
const [exercise, setExercise] = useState(null)
const [error, setError] = useState(null)
const [loading, setLoading] = useState(true)
@ -104,16 +105,19 @@ function ExerciseDetailPage() {
if (!exercise) return null
const meta = metaParts(exercise)
const fromExerciseEdit = location.state?.fromExerciseEdit === true
return (
<div className="exercise-detail-shell" style={{ padding: '12px 12px 24px' }}>
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap' }}>
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}>
Übersicht
</button>
<Link to={`/exercises/${exercise.id}/edit`} className="btn btn-primary">
Bearbeiten
</Link>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginLeft: 'auto' }}>
<Link to={`/exercises/${exercise.id}/edit`} className="btn btn-primary">
{fromExerciseEdit ? 'Zurück zur Bearbeitung' : 'Bearbeiten'}
</Link>
</div>
</div>
<div className="card exercise-detail-section">

View File

@ -9,6 +9,7 @@ import {
SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
buildExerciseMediaDragPayload,
} from '../utils/exerciseInlineMediaRefs'
import { autoScrollForDragNearEdges } from '../utils/dragAutoScroll'
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
import { useAuth } from '../context/AuthContext'
@ -373,6 +374,7 @@ function ExerciseFormPage() {
const [mediaList, setMediaList] = useState([])
const [loading, setLoading] = useState(!!isEdit)
const [saving, setSaving] = useState(false)
const [formDirty, setFormDirty] = useState(false)
const [skillPick, setSkillPick] = useState('')
const [variants, setVariants] = useState([])
const [variantDraft, setVariantDraft] = useState(() => emptyVariantDraft())
@ -385,7 +387,6 @@ function ExerciseFormPage() {
const [mediaSavingId, setMediaSavingId] = useState(null)
const [archiveOpen, setArchiveOpen] = useState(false)
const [archiveQ, setArchiveQ] = useState('')
const [archiveCtx, setArchiveCtx] = useState('ablauf')
const [archiveLoading, setArchiveLoading] = useState(false)
const [archiveItems, setArchiveItems] = useState([])
const [archiveError, setArchiveError] = useState(null)
@ -396,12 +397,32 @@ function ExerciseFormPage() {
for (const m of mediaList) {
next[m.id] = {
title: m.title || '',
context: m.context || 'ablauf',
}
}
setMediaFields(next)
}, [mediaList])
useEffect(() => {
const onDragOverDoc = (e) => {
const types = e.dataTransfer?.types ? Array.from(e.dataTransfer.types) : []
if (!types.includes(SHINKAN_EXERCISE_MEDIA_DRAG_MIME)) return
e.preventDefault()
autoScrollForDragNearEdges(e)
}
document.addEventListener('dragover', onDragOverDoc)
return () => document.removeEventListener('dragover', onDragOverDoc)
}, [])
useEffect(() => {
if (!formDirty) return undefined
const warn = (ev) => {
ev.preventDefault()
ev.returnValue = ''
}
window.addEventListener('beforeunload', warn)
return () => window.removeEventListener('beforeunload', warn)
}, [formDirty])
useEffect(() => {
if (!archiveOpen) return undefined
let cancelled = false
@ -466,6 +487,7 @@ function ExerciseFormPage() {
setVariants([])
setVariantDraft(emptyVariantDraft())
setVariantEditSelection(null)
setFormDirty(false)
setLoading(false)
return
}
@ -480,6 +502,7 @@ function ExerciseFormPage() {
setVariants((exercise.variants || []).map(apiVariantToRow))
setVariantDraft(emptyVariantDraft())
setVariantEditSelection(null)
setFormDirty(false)
} catch (err) {
if (!cancelled) {
alert(err.message || 'Übung nicht ladbar')
@ -509,6 +532,7 @@ function ExerciseFormPage() {
}, [variantEditSelection])
const updateFormField = (field, value) => {
setFormDirty(true)
setFormData((prev) => ({ ...prev, [field]: value }))
}
@ -646,6 +670,7 @@ function ExerciseFormPage() {
const ex = await api.getExercise(exerciseId)
setMediaList(ex.media || [])
setVariants((ex.variants || []).map(apiVariantToRow))
setFormDirty(false)
alert('Gespeichert.')
} else {
const created = await api.createExercise(payload)
@ -669,7 +694,7 @@ function ExerciseFormPage() {
try {
await api.attachExerciseMediaFromAsset(exerciseId, {
media_asset_id: assetId,
context: archiveCtx,
context: 'ablauf',
title: '',
description: '',
is_primary: false,
@ -689,7 +714,8 @@ function ExerciseFormPage() {
const handleDeleteMedia = async (mid) => {
if (
!confirm(
'Dieses Medium aus der Übung entfernen? Nur die Verknüpfung wird gelöscht. Die Datei bleibt im Archiv, solange sie noch woanders genutzt wird.',
'Dieses Medium aus der Übung entfernen? Nur die Verknüpfung wird gelöscht. Die Datei bleibt im Archiv, solange sie noch woanders genutzt wird.\n\n' +
'Hinweis: Wenn dieser Eintrag noch als Platzhalter im Fließtext steht, zeigt die Vorschau [Medium nicht verfügbar] oder das Speichern der Übung schlägt fehl, bis der Platzhalter entfernt ist.',
)
) {
return
@ -740,7 +766,6 @@ function ExerciseFormPage() {
try {
await api.updateExerciseMedia(exerciseId, mid, {
title: fld.title.trim() || null,
context: fld.context,
})
await refreshMedia()
} catch (e) {
@ -757,6 +782,7 @@ function ExerciseFormPage() {
}
const updateVariantField = (id, patch) => {
setFormDirty(true)
setVariants((prev) => prev.map((v) => (v.id === id ? { ...v, ...patch } : v)))
}
@ -860,7 +886,18 @@ function ExerciseFormPage() {
type="button"
className="btn btn-secondary"
style={{ marginLeft: '8px' }}
onClick={() => navigate(`/exercises/${exerciseId}`)}
onClick={() => {
if (
formDirty &&
!window.confirm(
'Es gibt noch nicht über „Speichern“ gesicherte Änderungen (Texte, Zuordnungen, …).\n\n' +
'Zur Ansicht wechseln und diese Änderungen verwerfen?',
)
) {
return
}
navigate(`/exercises/${exerciseId}`, { state: { fromExerciseEdit: true } })
}}
>
Ansehen
</button>
@ -1236,7 +1273,10 @@ function ExerciseFormPage() {
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Neue Variante</h3>
<ExerciseVariantFields
row={variantDraft}
onPatch={(patch) => setVariantDraft((d) => ({ ...d, ...patch }))}
onPatch={(patch) => {
setFormDirty(true)
setVariantDraft((d) => ({ ...d, ...patch }))
}}
prerequisiteOthers={variants}
rteMinHeight="110px"
inlineExerciseId={isEdit ? exerciseId : null}
@ -1417,37 +1457,17 @@ function ExerciseFormPage() {
<input
type="text"
className="form-input exercise-edit-media-strip__title"
placeholder="Titel"
placeholder="Titel (wird in der Vorschau und im Platzhalter genutzt)"
value={(mediaFields[m.id] || {}).title ?? ''}
onChange={(e) =>
setMediaFields((prev) => ({
...prev,
[m.id]: {
...(prev[m.id] || {}),
title: e.target.value,
context: (prev[m.id] || {}).context || 'ablauf',
},
}))
}
/>
<select
className="form-input exercise-edit-media-strip__ctx"
value={(mediaFields[m.id] || {}).context || 'ablauf'}
onChange={(e) =>
setMediaFields((prev) => ({
...prev,
[m.id]: {
...(prev[m.id] || {}),
title: (prev[m.id] || {}).title ?? '',
context: e.target.value,
},
}))
}
>
<option value="ablauf">Ablauf</option>
<option value="detail">Detail</option>
<option value="trainer_hint">Trainer-Hinweis</option>
</select>
</div>
<div className="exercise-edit-media-strip__actions">
{mediaList.length > 1 && (
@ -1498,6 +1518,10 @@ function ExerciseFormPage() {
})}
</ul>
)}
<p style={{ color: 'var(--text3)', fontSize: '12px', marginTop: mediaList.length ? '12px' : 0 }}>
Verknüpfungen bleiben nötig (u. a. Zugriff, Orphan-Hinweise): Im Fließtext verweist du gezielt über
Platzhalter. Ohne Verknüpfung gäbe es keine exercise_media-ID zum Einbetten.
</p>
{archiveOpen && (
<div
role="dialog"
@ -1534,16 +1558,6 @@ function ExerciseFormPage() {
onChange={(e) => setArchiveQ(e.target.value)}
style={{ marginBottom: '8px' }}
/>
<select
className="form-input"
value={archiveCtx}
onChange={(e) => setArchiveCtx(e.target.value)}
style={{ marginBottom: '12px' }}
>
<option value="ablauf">Sektion: Ablauf</option>
<option value="detail">Sektion: Detail</option>
<option value="trainer_hint">Sektion: Trainer-Hinweis</option>
</select>
{archiveLoading && <p style={{ fontSize: '13px', color: 'var(--text3)' }}>Laden</p>}
{archiveError && <p style={{ fontSize: '13px', color: 'var(--danger)' }}>{archiveError}</p>}
{!archiveLoading && !archiveError && archiveItems.length === 0 && (

View File

@ -0,0 +1,66 @@
/**
* Während eines Drags automatischen Bildlauf auslösen (Viewport + scrollbare Bereiche unter dem Cursor).
* @param {DragEvent} e
* @param {{ edgePx?: number, scrollStep?: number }} [opts]
*/
export function autoScrollForDragNearEdges(e, opts = {}) {
const edge = opts.edgePx ?? 80
const step = opts.scrollStep ?? Math.max(24, Math.round(window.innerHeight * 0.05))
const { clientX, clientY } = e
const vh = window.innerHeight || 0
const vw = window.innerWidth || 0
let sy =
clientY < edge ? -step : vh > 0 && clientY > vh - edge ? step : 0
let sx =
clientX < edge ? -step : vw > 0 && clientX > vw - edge ? step : 0
if (sy !== 0) window.scrollBy(0, sy)
if (sx !== 0) window.scrollBy(sx, 0)
/** @type {HTMLElement|null} */
let top = /** @type {HTMLElement|null} */ (document.elementFromPoint(clientX, clientY))
/** @type {Set<HTMLElement>} */
const done = new Set()
/** @type {HTMLElement|null} */
let walk = top
const innerEdge = Math.min(edge, 40)
while (walk && walk !== document.body) {
if (!(walk instanceof HTMLElement)) break
const cs = window.getComputedStyle(walk)
const canY =
walk.scrollHeight - walk.clientHeight > 6 &&
(cs.overflowY === 'auto' || cs.overflowY === 'scroll')
const canX =
walk.scrollWidth - walk.clientWidth > 6 &&
(cs.overflowX === 'auto' || cs.overflowX === 'scroll')
if ((canY || canX) && !done.has(walk)) {
done.add(walk)
const rect = walk.getBoundingClientRect()
const relY = clientY - rect.top
const relX = clientX - rect.left
if (canY) {
if (relY < innerEdge && walk.scrollTop > 0) walk.scrollTop -= step
else if (rect.height - relY < innerEdge && walk.scrollTop < walk.scrollHeight - walk.clientHeight) {
walk.scrollTop += step
}
}
if (canX) {
if (relX < innerEdge && walk.scrollLeft > 0) walk.scrollLeft -= step
else if (rect.width - relX < innerEdge && walk.scrollLeft < walk.scrollWidth - walk.clientWidth) {
walk.scrollLeft += step
}
}
}
walk = walk.parentElement
}
}