import React, { Fragment, useCallback, useEffect, useState } from 'react' import { GripVertical, Pencil } from 'lucide-react' import { defaultSection, exerciseRow, noteRow, sectionPlannedMinutes, } 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() if (t.length <= max) return t 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, onSectionsChange, onRequestExercisePick, onPeekExercise, showExecutionExtras = false, heading = 'Abschnitte & Übungen', hideHeading = false, headingAccessory = null, wideExerciseGrid = false, enableItemDragReorder = true, enableSectionDragReorder = true, slotIndex = null, onMoveSectionsAcrossSlots = null, }) { const ensure = (prev) => prev && prev.length ? prev : [defaultSection()] const patch = useCallback( (updater) => { onSectionsChange((prev) => updater(ensure(prev))) }, [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)) ) } const addSection = () => { patch((prev) => [...prev, defaultSection(`Abschnitt ${prev.length + 1}`)]) } const removeSection = (sIdx) => { patch((prev) => { const next = prev.filter((_, i) => i !== sIdx) return next.length ? next : [defaultSection()] }) } const moveSection = (sIdx, dir) => { patch((prev) => { const p = [...prev] const ta = sIdx + dir if (ta < 0 || ta >= p.length) return p ;[p[sIdx], p[ta]] = [p[ta], p[sIdx]] return p }) } const addItem = (sIdx, kind) => { patch((prev) => prev.map((s, i) => i !== sIdx ? s : { ...s, items: [...(s.items || []), kind === 'note' ? noteRow() : exerciseRow()], } ) ) } const removeItem = (sIdx, iIdx) => { patch((prev) => prev.map((s, si) => si !== sIdx ? s : { ...s, items: (s.items || []).filter((_, ii) => ii !== iIdx) } ) ) } const moveItem = (sIdx, iIdx, dir) => { patch((prev) => prev.map((s, si) => { if (si !== sIdx) return s const items = [...(s.items || [])] const ta = iIdx + dir if (ta < 0 || ta >= items.length) return s ;[items[iIdx], items[ta]] = [items[ta], items[iIdx]] return { ...s, items } }) ) } const updateItem = (sIdx, iIdx, field, val) => { patch((prev) => prev.map((s, si) => si !== sIdx ? s : { ...s, items: (s.items || []).map((row, ii) => ii === iIdx ? { ...row, [field]: val } : row ), } ) ) } const [textEdit, setTextEdit] = useState(null) 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) => { if (e.key === 'Escape') setTextEdit(null) } window.addEventListener('keydown', onKey) 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() try { e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData( DND_TU_ITEM, JSON.stringify({ sectionIndex: sIdx, itemIndex: iIdx }) ) } catch { /* ignore */ } setDraggingPos({ sIdx, iIdx }) } const clearDragChrome = () => { setDraggingPos(null) setDropTargetPos(null) } const onItemDragOverRow = (e, sIdx, iIdx) => { if (!enableItemDragReorder) return if (!dtHasType(e, DND_TU_ITEM)) return e.preventDefault() e.stopPropagation() try { e.dataTransfer.dropEffect = 'move' } catch { /* ignore */ } setDropTargetPos({ sIdx, iIdx }) } const onItemDropRow = (e, toSIdx, toIdx) => { if (!enableItemDragReorder) return e.preventDefault() e.stopPropagation() let raw = '' try { raw = e.dataTransfer.getData(DND_TU_ITEM) } catch { clearDragChrome() return } if (!raw) { clearDragChrome() return } let data try { data = JSON.parse(raw) } catch { clearDragChrome() return } const fromS = data.sectionIndex const fromI = data.itemIndex if (typeof fromS !== 'number' || typeof fromI !== 'number') { clearDragChrome() return } if (fromS === toSIdx && fromI === toIdx) { clearDragChrome() return } patch((prev) => { const list = ensure(prev) if ( fromS < 0 || fromS >= list.length || toSIdx < 0 || toSIdx >= list.length || typeof toIdx !== 'number' ) { return prev } const fromItems = [...(list[fromS].items || [])] if (fromI < 0 || fromI >= fromItems.length) return prev const moved = fromItems[fromI] fromItems.splice(fromI, 1) if (fromS === toSIdx) { let insertAt = toIdx if (fromI < toIdx) insertAt = toIdx - 1 const bounded = Math.max(0, Math.min(insertAt, fromItems.length)) fromItems.splice(bounded, 0, moved) return list.map((sec, i) => (i === fromS ? { ...sec, items: fromItems } : sec)) } const toItems = [...(list[toSIdx].items || [])] const insertAt = Math.max(0, Math.min(toIdx, toItems.length)) toItems.splice(insertAt, 0, moved) return list.map((sec, i) => { if (i === fromS) return { ...sec, items: fromItems } if (i === toSIdx) return { ...sec, items: toItems } return sec }) }) clearDragChrome() } const applyTextEdit = () => { if (!textEdit) return const { kind, sIdx, iIdx, draft } = textEdit if (kind === 'zwischen-note') { updateItem(sIdx, iIdx, 'note_body', draft) } else if (kind === 'exercise-notes') { updateItem(sIdx, iIdx, 'notes', draft) } setTextEdit(null) } const list = ensure(sections) return (
{(!hideHeading || headingAccessory) ? (
{!hideHeading ? (

{heading}

) : headingAccessory ? ( ) : null} {headingAccessory ? (
{headingAccessory}
) : null}
) : 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}
{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)" />