+
-
- Bearbeiten
-
+
+
+ {fromExerciseEdit ? 'Zurück zur Bearbeitung' : 'Bearbeiten'}
+
+
diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx
index 27bff02..a015b85 100644
--- a/frontend/src/pages/ExerciseFormPage.jsx
+++ b/frontend/src/pages/ExerciseFormPage.jsx
@@ -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
@@ -1236,7 +1273,10 @@ function ExerciseFormPage() {
Neue Variante
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() {
setMediaFields((prev) => ({
...prev,
[m.id]: {
- ...(prev[m.id] || {}),
title: e.target.value,
- context: (prev[m.id] || {}).context || 'ablauf',
},
}))
}
/>
-
{mediaList.length > 1 && (
@@ -1498,6 +1518,10 @@ function ExerciseFormPage() {
})}
)}
+
+ 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.
+
{archiveOpen && (
setArchiveQ(e.target.value)}
style={{ marginBottom: '8px' }}
/>
-
{archiveLoading &&
Laden…
}
{archiveError &&
{archiveError}
}
{!archiveLoading && !archiveError && archiveItems.length === 0 && (
diff --git a/frontend/src/utils/dragAutoScroll.js b/frontend/src/utils/dragAutoScroll.js
new file mode 100644
index 0000000..25f293e
--- /dev/null
+++ b/frontend/src/utils/dragAutoScroll.js
@@ -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
} */
+ 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
+ }
+}