diff --git a/frontend/src/app.css b/frontend/src/app.css
index 7ca5e2a..9868097 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -3462,6 +3462,57 @@ a.analysis-split__nav-item {
justify-content: flex-end;
}
+.tu-section-shell {
+ contain: layout;
+}
+
+.tu-section-dropband {
+ height: 10px;
+ margin: 0 2px 4px;
+ border-radius: 6px;
+ flex-shrink: 0;
+ box-sizing: border-box;
+}
+
+.tu-section-dropband--end {
+ margin-top: 2px;
+ margin-bottom: 10px;
+}
+
+.tu-section-dropband--active {
+ background: color-mix(in srgb, var(--accent) 26%, transparent);
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 42%, transparent);
+}
+
+.tu-sec-drag-grip {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ padding: 4px;
+ cursor: grab;
+ color: var(--text3);
+ user-select: none;
+ touch-action: none;
+ border-radius: 6px;
+}
+
+.tu-sec-drag-grip:active {
+ cursor: grabbing;
+}
+
+.tu-item-append-drop {
+ min-height: 16px;
+ margin: 2px -2px 6px;
+ border-radius: 6px;
+ box-sizing: border-box;
+}
+
+.tu-item-append-drop--active {
+ outline: 2px dashed var(--accent);
+ outline-offset: 1px;
+ background: color-mix(in srgb, var(--accent) 10%, transparent);
+}
+
.framework-slot-card__head {
display: flex;
flex-direction: row;
diff --git a/frontend/src/components/ExercisePeekModal.jsx b/frontend/src/components/ExercisePeekModal.jsx
index 1063e7d..56a1c02 100644
--- a/frontend/src/components/ExercisePeekModal.jsx
+++ b/frontend/src/components/ExercisePeekModal.jsx
@@ -31,11 +31,22 @@ function TagMini({ exercise }) {
)
}
-export default function ExercisePeekModal({ open, exerciseId, onClose, titleFallback }) {
+export default function ExercisePeekModal({
+ open,
+ exerciseId,
+ variantId,
+ onClose,
+ titleFallback,
+}) {
const [loading, setLoading] = useState(false)
const [err, setErr] = useState(null)
const [exercise, setExercise] = useState(null)
+ const variant =
+ variantId != null && variantId !== '' && exercise?.variants?.length
+ ? exercise.variants.find((v) => String(v.id) === String(variantId)) || null
+ : null
+
useEffect(() => {
if (!open) {
setExercise(null)
@@ -62,7 +73,7 @@ export default function ExercisePeekModal({ open, exerciseId, onClose, titleFall
return () => {
cancelled = true
}
- }, [open, exerciseId])
+ }, [open, exerciseId, variantId])
if (!open) return null
@@ -100,6 +111,37 @@ export default function ExercisePeekModal({ open, exerciseId, onClose, titleFall
{!loading && err &&
diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx
index 8525252..7424b95 100644
--- a/frontend/src/components/ExercisePickerModal.jsx
+++ b/frontend/src/components/ExercisePickerModal.jsx
@@ -22,7 +22,13 @@ const INITIAL_FILTERS = {
status_any: [],
}
-export default function ExercisePickerModal({ open, onClose, onSelectExercise }) {
+export default function ExercisePickerModal({
+ open,
+ onClose,
+ onSelectExercise,
+ multiSelect = false,
+ onSelectExercises = null,
+}) {
const [catalogs, setCatalogs] = useState({
focusAreas: [],
styleDirections: [],
@@ -42,6 +48,13 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
const [loadingMore, setLoadingMore] = useState(false)
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(false)
+ const [multiPicked, setMultiPicked] = useState([])
+
+ const toggleMultiPick = (ex) => {
+ setMultiPicked((prev) =>
+ prev.some((p) => p.id === ex.id) ? prev.filter((p) => p.id !== ex.id) : [...prev, ex]
+ )
+ }
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 350)
@@ -96,6 +109,7 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
setList([])
setOffset(0)
setHasMore(false)
+ setMultiPicked([])
}
}, [open])
@@ -230,7 +244,9 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
onClick={(e) => e.stopPropagation()}
>
-
Übung auswählen
+
+ {multiSelect ? 'Übungen auswählen' : 'Übung auswählen'}
+
@@ -391,29 +407,16 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
{list.length} angezeigt{hasMore ? ' · weiter unten „Mehr laden“' : ''}
- {list.map((ex) => (
- -
-
-
- ))}
+ >
+ )
+ if (multiSelect) {
+ return (
+ -
+
+
+ )
+ }
+ return (
+ -
+
+
+ )
+ })}
{hasMore && (
@@ -432,6 +490,49 @@ export default function ExercisePickerModal({ open, onClose, onSelectExercise })
)}
+ {multiSelect && typeof onSelectExercises === 'function' ? (
+
+
+ {multiPicked.length} ausgewählt
+
+
+
+
+
+
+ ) : null}
>
)}
diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx
index 3faa629..4a4cb10 100644
--- a/frontend/src/components/TrainingUnitSectionsEditor.jsx
+++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useState } from 'react'
+import React, { Fragment, useCallback, useEffect, useState } from 'react'
import { GripVertical, Pencil } from 'lucide-react'
import {
defaultSection,
@@ -8,6 +8,14 @@ import {
} from '../utils/trainingUnitSectionsForm'
const DND_TU_ITEM = 'application/x-shinkan-training-unit-item'
+const DND_TU_SECTION = 'application/x-shinkan-training-section-v1'
+
+function dtHasType(e, mime) {
+ const t = e?.dataTransfer?.types
+ if (!t || !mime) return false
+ if (typeof t.contains === 'function' && t.contains(mime)) return true
+ return Array.from(t).includes(mime)
+}
function truncatePreview(text, max = 160) {
const t = (text || '').replace(/\s+/g, ' ').trim()
@@ -15,8 +23,20 @@ function truncatePreview(text, max = 160) {
return `${t.slice(0, max - 1)}…`
}
+function reorderBlocksImmutable(blocks, fromI, toBeforeIdx) {
+ const b = [...blocks]
+ if (fromI < 0 || fromI >= b.length) return blocks
+ const [moved] = b.splice(fromI, 1)
+ let insertAt = toBeforeIdx
+ if (fromI < toBeforeIdx) insertAt = toBeforeIdx - 1
+ insertAt = Math.max(0, Math.min(insertAt, b.length))
+ b.splice(insertAt, 0, moved)
+ return b
+}
+
/**
* @param {(updater: (prev: Array) => Array) => void} props.onSectionsChange — wie React setState
+ * @param {(p: { fromSlot: number, fromSectionIdx: number, toSlot: number, toSectionIdx: number }) => void} [props.onMoveSectionsAcrossSlots] — Rahmenprogramm: Abschnitt zwischen Slots verschieben
*/
export default function TrainingUnitSectionsEditor({
sections,
@@ -28,6 +48,9 @@ export default function TrainingUnitSectionsEditor({
hideHeading = false,
wideExerciseGrid = false,
enableItemDragReorder = true,
+ enableSectionDragReorder = true,
+ slotIndex = null,
+ onMoveSectionsAcrossSlots = null,
}) {
const ensure = (prev) =>
prev && prev.length ? prev : [defaultSection()]
@@ -39,6 +62,9 @@ export default function TrainingUnitSectionsEditor({
[onSectionsChange]
)
+ const sectionToSlot =
+ slotIndex !== null && slotIndex !== undefined ? Number(slotIndex) : -1
+
const updateSectionField = (sIdx, field, val) => {
patch((prev) =>
prev.map((s, i) => (i === sIdx ? { ...s, [field]: val } : s))
@@ -119,6 +145,9 @@ export default function TrainingUnitSectionsEditor({
const [draggingPos, setDraggingPos] = useState(null)
const [dropTargetPos, setDropTargetPos] = useState(null)
+ const [dropSectionBand, setDropSectionBand] = useState(null)
+ /** { slot: number, beforeIdx: number } */
+
useEffect(() => {
if (!textEdit) return
const onKey = (e) => {
@@ -128,6 +157,78 @@ export default function TrainingUnitSectionsEditor({
return () => window.removeEventListener('keydown', onKey)
}, [textEdit])
+ const clearSectionDnD = () => setDropSectionBand(null)
+
+ const onSectionDragStart = (e, sIdx) => {
+ if (!enableSectionDragReorder) return
+ e.stopPropagation()
+ try {
+ e.dataTransfer.effectAllowed = 'move'
+ e.dataTransfer.setData(
+ DND_TU_SECTION,
+ JSON.stringify({
+ fromSlot: sectionToSlot,
+ fromSectionIdx: sIdx,
+ })
+ )
+ } catch {
+ /* ignore */
+ }
+ setDropSectionBand(null)
+ }
+
+ const onSectionBandDragOver = (e, beforeIdx) => {
+ if (!enableSectionDragReorder) return
+ if (!dtHasType(e, DND_TU_SECTION)) return
+ e.preventDefault()
+ e.stopPropagation()
+ try {
+ e.dataTransfer.dropEffect = 'move'
+ } catch {
+ /* ignore */
+ }
+ setDropSectionBand({ slot: sectionToSlot, beforeIdx })
+ }
+
+ const onSectionBandDrop = (e, insertBeforeIdx) => {
+ if (!enableSectionDragReorder) return
+ e.preventDefault()
+ e.stopPropagation()
+ clearSectionDnD()
+ let raw = ''
+ try {
+ raw = e.dataTransfer.getData(DND_TU_SECTION)
+ } catch {
+ return
+ }
+ if (!raw) return
+ let data
+ try {
+ data = JSON.parse(raw)
+ } catch {
+ return
+ }
+ const fromSi = data.fromSectionIdx
+ const fromSlot = typeof data.fromSlot === 'number' ? data.fromSlot : -1
+ if (typeof fromSi !== 'number') return
+
+ if (
+ typeof onMoveSectionsAcrossSlots === 'function' &&
+ sectionToSlot >= 0 &&
+ fromSlot >= 0
+ ) {
+ onMoveSectionsAcrossSlots({
+ fromSlot,
+ fromSectionIdx: fromSi,
+ toSlot: sectionToSlot,
+ toSectionIdx: insertBeforeIdx,
+ })
+ return
+ }
+
+ patch((prev) => reorderBlocksImmutable(prev, fromSi, insertBeforeIdx))
+ }
+
const onItemDragStart = (e, sIdx, iIdx) => {
if (!enableItemDragReorder) return
e.stopPropagation()
@@ -150,6 +251,7 @@ export default function TrainingUnitSectionsEditor({
const onItemDragOverRow = (e, sIdx, iIdx) => {
if (!enableItemDragReorder) return
+ if (!dtHasType(e, DND_TU_ITEM)) return
e.preventDefault()
e.stopPropagation()
try {
@@ -256,380 +358,474 @@ export default function TrainingUnitSectionsEditor({
) : null}
{list.map((sec, sIdx) => {
const planMin = sectionPlannedMinutes(sec)
+ const itemCount = sec.items?.length ?? 0
+ const bandActiveBefore = (bx) =>
+ enableSectionDragReorder &&
+ dropSectionBand &&
+ dropSectionBand.slot === sectionToSlot &&
+ dropSectionBand.beforeIdx === bx
+
return (
-
+
+ {enableSectionDragReorder ? (
+ {
+ if (!enableSectionDragReorder) return
+ if (!dtHasType(e, DND_TU_SECTION)) return
+ onSectionBandDragOver(e, sIdx)
+ }}
+ onDragLeave={(e) => {
+ if (e.currentTarget.contains(e.relatedTarget)) return
+ clearSectionDnD()
+ }}
+ onDrop={(e) => onSectionBandDrop(e, sIdx)}
+ />
+ ) : null}
-
updateSectionField(sIdx, 'title', e.target.value)}
- placeholder="Abschnittstitel (z. B. Aufwärmen)"
- />
-
+
+ {enableSectionDragReorder ? (
+
onSectionDragStart(e, sIdx)}
+ role="button"
+ tabIndex={0}
+ aria-label="Abschnitt ziehen"
+ title="Abschnitt ziehen"
+ >
+
+
+ ) : null}
+
updateSectionField(sIdx, 'title', e.target.value)}
+ placeholder="Abschnittstitel (z. B. Aufwärmen)"
+ />
+
+
+
+
-
-
-
-
)