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 &&

{err}

} {!loading && exercise && ( <> + {variant ? ( +
+
+ Variante +
+
+ {variant.variant_name || `Variante #${variant.id}`} +
+ {variant.description ? ( +
+ +
+ ) : null} + {variant.execution_changes ? ( +
+

+ Durchführung (Variante) +

+ +
+ ) : null} +
+ ) : null} {exercise.summary && (
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)" + /> +
+ + +
-
- -
-