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
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:
parent
337f29401b
commit
5cf775c920
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.63"
|
APP_VERSION = "0.8.64"
|
||||||
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.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",
|
"version": "0.8.63",
|
||||||
"date": "2026-05-08",
|
"date": "2026-05-08",
|
||||||
|
|
|
||||||
|
|
@ -4018,6 +4018,13 @@ a.analysis-split__nav-item {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
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 {
|
.rte-inline-asset-tile__thumb-fallback {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text3);
|
color: var(--text3);
|
||||||
|
|
@ -4066,9 +4073,10 @@ a.analysis-split__nav-item {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 14px 0 0;
|
margin: 14px 0 0;
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.exercise-edit-media-strip__item {
|
.exercise-edit-media-strip__item {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -4129,15 +4137,10 @@ a.analysis-split__nav-item {
|
||||||
}
|
}
|
||||||
.exercise-edit-media-strip__toolbar {
|
.exercise-edit-media-strip__toolbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr minmax(120px, 160px);
|
grid-template-columns: 1fr;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
@media (max-width: 520px) {
|
|
||||||
.exercise-edit-media-strip__toolbar {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.exercise-edit-media-strip__actions {
|
.exercise-edit-media-strip__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
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 ExerciseAttachmentMediaStrip from './ExerciseAttachmentMediaStrip'
|
|
||||||
|
|
||||||
function TagRow({ exercise }) {
|
function TagRow({ exercise }) {
|
||||||
const tags = []
|
const tags = []
|
||||||
|
|
@ -118,7 +117,6 @@ 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>
|
||||||
)}
|
)}
|
||||||
<ExerciseAttachmentMediaStrip exercise={exercise} exerciseId={resolvedId} />
|
|
||||||
{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' }}>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,41 @@ import {
|
||||||
} from '../constants/inlineExerciseMedia'
|
} from '../constants/inlineExerciseMedia'
|
||||||
import { sanitizeInlineMediaCaption } from '../utils/inlineMediaCaption'
|
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 */
|
/** MIME/Dateiname → Übungs-media_type */
|
||||||
function inferExerciseMediaType(file) {
|
function inferExerciseMediaType(file) {
|
||||||
if (!file) return 'image'
|
if (!file) return 'image'
|
||||||
|
|
@ -263,9 +298,6 @@ export default function ExerciseInlineFileMediaModal({
|
||||||
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 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}
|
||||||
|
|
@ -275,13 +307,7 @@ export default function ExerciseInlineFileMediaModal({
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
>
|
>
|
||||||
<div className="rte-inline-asset-tile__thumb" aria-hidden>
|
<div className="rte-inline-asset-tile__thumb" aria-hidden>
|
||||||
{isImg && src ? (
|
<RtePickerAssetThumb asset={it} />
|
||||||
<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>
|
</div>
|
||||||
{linked ? (
|
{linked ? (
|
||||||
<span className="rte-inline-asset-tile__badge">Bereits verknüpft</span>
|
<span className="rte-inline-asset-tile__badge">Bereits verknüpft</span>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
|
SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
|
||||||
parseExerciseMediaDragPayload,
|
parseExerciseMediaDragPayload,
|
||||||
} from '../utils/exerciseInlineMediaRefs'
|
} from '../utils/exerciseInlineMediaRefs'
|
||||||
|
import { autoScrollForDragNearEdges } from '../utils/dragAutoScroll'
|
||||||
|
|
||||||
function exec(cmd, value = null) {
|
function exec(cmd, value = null) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -265,6 +266,7 @@ export default function RichTextEditor({
|
||||||
if (types.includes(SHINKAN_EXERCISE_MEDIA_DRAG_MIME)) {
|
if (types.includes(SHINKAN_EXERCISE_MEDIA_DRAG_MIME)) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.dataTransfer.dropEffect = 'copy'
|
e.dataTransfer.dropEffect = 'copy'
|
||||||
|
autoScrollForDragNearEdges(e)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[showInlineToolbar],
|
[showInlineToolbar],
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useEffect, useState } from 'react'
|
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 api from '../utils/api'
|
||||||
import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
|
import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock'
|
||||||
import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaStrip'
|
import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaStrip'
|
||||||
|
|
@ -53,6 +53,7 @@ function metaParts(exercise) {
|
||||||
function ExerciseDetailPage() {
|
function ExerciseDetailPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
const [exercise, setExercise] = useState(null)
|
const [exercise, setExercise] = useState(null)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
@ -104,16 +105,19 @@ function ExerciseDetailPage() {
|
||||||
if (!exercise) return null
|
if (!exercise) return null
|
||||||
|
|
||||||
const meta = metaParts(exercise)
|
const meta = metaParts(exercise)
|
||||||
|
const fromExerciseEdit = location.state?.fromExerciseEdit === true
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="exercise-detail-shell" style={{ padding: '12px 12px 24px' }}>
|
<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')}>
|
<button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}>
|
||||||
← Übersicht
|
← Übersicht
|
||||||
</button>
|
</button>
|
||||||
<Link to={`/exercises/${exercise.id}/edit`} className="btn btn-primary">
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginLeft: 'auto' }}>
|
||||||
Bearbeiten
|
<Link to={`/exercises/${exercise.id}/edit`} className="btn btn-primary">
|
||||||
</Link>
|
{fromExerciseEdit ? 'Zurück zur Bearbeitung' : 'Bearbeiten'}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card exercise-detail-section">
|
<div className="card exercise-detail-section">
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
|
SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
|
||||||
buildExerciseMediaDragPayload,
|
buildExerciseMediaDragPayload,
|
||||||
} from '../utils/exerciseInlineMediaRefs'
|
} from '../utils/exerciseInlineMediaRefs'
|
||||||
|
import { autoScrollForDragNearEdges } from '../utils/dragAutoScroll'
|
||||||
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'
|
||||||
|
|
||||||
|
|
@ -373,6 +374,7 @@ function ExerciseFormPage() {
|
||||||
const [mediaList, setMediaList] = useState([])
|
const [mediaList, setMediaList] = useState([])
|
||||||
const [loading, setLoading] = useState(!!isEdit)
|
const [loading, setLoading] = useState(!!isEdit)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [formDirty, setFormDirty] = useState(false)
|
||||||
const [skillPick, setSkillPick] = useState('')
|
const [skillPick, setSkillPick] = useState('')
|
||||||
const [variants, setVariants] = useState([])
|
const [variants, setVariants] = useState([])
|
||||||
const [variantDraft, setVariantDraft] = useState(() => emptyVariantDraft())
|
const [variantDraft, setVariantDraft] = useState(() => emptyVariantDraft())
|
||||||
|
|
@ -385,7 +387,6 @@ function ExerciseFormPage() {
|
||||||
const [mediaSavingId, setMediaSavingId] = useState(null)
|
const [mediaSavingId, setMediaSavingId] = useState(null)
|
||||||
const [archiveOpen, setArchiveOpen] = useState(false)
|
const [archiveOpen, setArchiveOpen] = useState(false)
|
||||||
const [archiveQ, setArchiveQ] = useState('')
|
const [archiveQ, setArchiveQ] = useState('')
|
||||||
const [archiveCtx, setArchiveCtx] = useState('ablauf')
|
|
||||||
const [archiveLoading, setArchiveLoading] = useState(false)
|
const [archiveLoading, setArchiveLoading] = useState(false)
|
||||||
const [archiveItems, setArchiveItems] = useState([])
|
const [archiveItems, setArchiveItems] = useState([])
|
||||||
const [archiveError, setArchiveError] = useState(null)
|
const [archiveError, setArchiveError] = useState(null)
|
||||||
|
|
@ -396,12 +397,32 @@ function ExerciseFormPage() {
|
||||||
for (const m of mediaList) {
|
for (const m of mediaList) {
|
||||||
next[m.id] = {
|
next[m.id] = {
|
||||||
title: m.title || '',
|
title: m.title || '',
|
||||||
context: m.context || 'ablauf',
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setMediaFields(next)
|
setMediaFields(next)
|
||||||
}, [mediaList])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!archiveOpen) return undefined
|
if (!archiveOpen) return undefined
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
@ -466,6 +487,7 @@ function ExerciseFormPage() {
|
||||||
setVariants([])
|
setVariants([])
|
||||||
setVariantDraft(emptyVariantDraft())
|
setVariantDraft(emptyVariantDraft())
|
||||||
setVariantEditSelection(null)
|
setVariantEditSelection(null)
|
||||||
|
setFormDirty(false)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -480,6 +502,7 @@ function ExerciseFormPage() {
|
||||||
setVariants((exercise.variants || []).map(apiVariantToRow))
|
setVariants((exercise.variants || []).map(apiVariantToRow))
|
||||||
setVariantDraft(emptyVariantDraft())
|
setVariantDraft(emptyVariantDraft())
|
||||||
setVariantEditSelection(null)
|
setVariantEditSelection(null)
|
||||||
|
setFormDirty(false)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
alert(err.message || 'Übung nicht ladbar')
|
alert(err.message || 'Übung nicht ladbar')
|
||||||
|
|
@ -509,6 +532,7 @@ function ExerciseFormPage() {
|
||||||
}, [variantEditSelection])
|
}, [variantEditSelection])
|
||||||
|
|
||||||
const updateFormField = (field, value) => {
|
const updateFormField = (field, value) => {
|
||||||
|
setFormDirty(true)
|
||||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -646,6 +670,7 @@ function ExerciseFormPage() {
|
||||||
const ex = await api.getExercise(exerciseId)
|
const ex = await api.getExercise(exerciseId)
|
||||||
setMediaList(ex.media || [])
|
setMediaList(ex.media || [])
|
||||||
setVariants((ex.variants || []).map(apiVariantToRow))
|
setVariants((ex.variants || []).map(apiVariantToRow))
|
||||||
|
setFormDirty(false)
|
||||||
alert('Gespeichert.')
|
alert('Gespeichert.')
|
||||||
} else {
|
} else {
|
||||||
const created = await api.createExercise(payload)
|
const created = await api.createExercise(payload)
|
||||||
|
|
@ -669,7 +694,7 @@ function ExerciseFormPage() {
|
||||||
try {
|
try {
|
||||||
await api.attachExerciseMediaFromAsset(exerciseId, {
|
await api.attachExerciseMediaFromAsset(exerciseId, {
|
||||||
media_asset_id: assetId,
|
media_asset_id: assetId,
|
||||||
context: archiveCtx,
|
context: 'ablauf',
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
is_primary: false,
|
is_primary: false,
|
||||||
|
|
@ -689,7 +714,8 @@ function ExerciseFormPage() {
|
||||||
const handleDeleteMedia = async (mid) => {
|
const handleDeleteMedia = async (mid) => {
|
||||||
if (
|
if (
|
||||||
!confirm(
|
!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
|
return
|
||||||
|
|
@ -740,7 +766,6 @@ function ExerciseFormPage() {
|
||||||
try {
|
try {
|
||||||
await api.updateExerciseMedia(exerciseId, mid, {
|
await api.updateExerciseMedia(exerciseId, mid, {
|
||||||
title: fld.title.trim() || null,
|
title: fld.title.trim() || null,
|
||||||
context: fld.context,
|
|
||||||
})
|
})
|
||||||
await refreshMedia()
|
await refreshMedia()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -757,6 +782,7 @@ function ExerciseFormPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateVariantField = (id, patch) => {
|
const updateVariantField = (id, patch) => {
|
||||||
|
setFormDirty(true)
|
||||||
setVariants((prev) => prev.map((v) => (v.id === id ? { ...v, ...patch } : v)))
|
setVariants((prev) => prev.map((v) => (v.id === id ? { ...v, ...patch } : v)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -860,7 +886,18 @@ function ExerciseFormPage() {
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
style={{ marginLeft: '8px' }}
|
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
|
Ansehen
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1236,7 +1273,10 @@ function ExerciseFormPage() {
|
||||||
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Neue Variante</h3>
|
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Neue Variante</h3>
|
||||||
<ExerciseVariantFields
|
<ExerciseVariantFields
|
||||||
row={variantDraft}
|
row={variantDraft}
|
||||||
onPatch={(patch) => setVariantDraft((d) => ({ ...d, ...patch }))}
|
onPatch={(patch) => {
|
||||||
|
setFormDirty(true)
|
||||||
|
setVariantDraft((d) => ({ ...d, ...patch }))
|
||||||
|
}}
|
||||||
prerequisiteOthers={variants}
|
prerequisiteOthers={variants}
|
||||||
rteMinHeight="110px"
|
rteMinHeight="110px"
|
||||||
inlineExerciseId={isEdit ? exerciseId : null}
|
inlineExerciseId={isEdit ? exerciseId : null}
|
||||||
|
|
@ -1417,37 +1457,17 @@ function ExerciseFormPage() {
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-input exercise-edit-media-strip__title"
|
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 ?? ''}
|
value={(mediaFields[m.id] || {}).title ?? ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setMediaFields((prev) => ({
|
setMediaFields((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[m.id]: {
|
[m.id]: {
|
||||||
...(prev[m.id] || {}),
|
|
||||||
title: e.target.value,
|
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>
|
||||||
<div className="exercise-edit-media-strip__actions">
|
<div className="exercise-edit-media-strip__actions">
|
||||||
{mediaList.length > 1 && (
|
{mediaList.length > 1 && (
|
||||||
|
|
@ -1498,6 +1518,10 @@ function ExerciseFormPage() {
|
||||||
})}
|
})}
|
||||||
</ul>
|
</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 && (
|
{archiveOpen && (
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
|
|
@ -1534,16 +1558,6 @@ function ExerciseFormPage() {
|
||||||
onChange={(e) => setArchiveQ(e.target.value)}
|
onChange={(e) => setArchiveQ(e.target.value)}
|
||||||
style={{ marginBottom: '8px' }}
|
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>}
|
{archiveLoading && <p style={{ fontSize: '13px', color: 'var(--text3)' }}>Laden…</p>}
|
||||||
{archiveError && <p style={{ fontSize: '13px', color: 'var(--danger)' }}>{archiveError}</p>}
|
{archiveError && <p style={{ fontSize: '13px', color: 'var(--danger)' }}>{archiveError}</p>}
|
||||||
{!archiveLoading && !archiveError && archiveItems.length === 0 && (
|
{!archiveLoading && !archiveError && archiveItems.length === 0 && (
|
||||||
|
|
|
||||||
66
frontend/src/utils/dragAutoScroll.js
Normal file
66
frontend/src/utils/dragAutoScroll.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user