From bfaf532ab2554ed1c3109878978495421abf72d0 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 12 May 2026 22:05:22 +0200 Subject: [PATCH] feat(training-units): enhance section editing with insert functionality - Added new CSS styles for insert slots and buttons to improve UI for adding items between sections. - Implemented functionality in the TrainingUnitSectionsEditor to allow users to insert notes and exercises at specified positions within sections. - Updated the TrainingFrameworkProgramEditPage to support the new insert functionality, ensuring seamless integration with existing features. - Enhanced state management to handle insert positions effectively, improving user experience during section editing. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/app.css | 58 ++++ .../components/TrainingUnitSectionsEditor.jsx | 250 +++++++++++++++--- .../TrainingFrameworkProgramEditPage.jsx | 17 +- frontend/src/pages/TrainingPlanningPage.jsx | 79 ++++-- 4 files changed, 338 insertions(+), 66 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index 44d1318..9f93655 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -5128,6 +5128,64 @@ a.analysis-split__nav-item { letter-spacing: 0.01em; } +/* Einfügen zwischen Ablaufzeilen (Übung / Modul / Anmerkung) */ +.tu-insert-slot { + display: flex; + align-items: center; + justify-content: center; + min-height: 11px; + margin: -1px 0 3px; + padding: 0 4px; +} + +.tu-insert-slot__btn { + appearance: none; + margin: 0; + cursor: pointer; + border: 1px dashed var(--border2); + background: color-mix(in srgb, var(--surface2) 90%, transparent); + color: var(--accent-dark); + font-size: 0.9rem; + font-weight: 700; + line-height: 1; + padding: 2px 9px; + border-radius: 999px; + opacity: 0.78; +} + +.tu-insert-slot__btn:hover, +.tu-insert-slot__btn:focus-visible { + opacity: 1; + outline: none; + border-color: var(--accent); + background: color-mix(in srgb, var(--accent-light) 40%, var(--surface2)); +} + +.tu-insert-chooser-actions { + display: flex; + flex-direction: column; + gap: 10px; +} + +.tu-insert-chooser-actions__full { + width: 100%; + justify-content: center; +} + +.tu-item-row--separator-note { + padding-top: 0.35rem; + padding-bottom: 0.35rem; +} + +.tu-item-row__separator-line { + width: 100%; + margin: 0.2rem 0 0; + min-height: 1px; + border: none; + border-top: 2px solid var(--border); + opacity: 0.92; +} + .tu-item-row { display: flex; flex-wrap: wrap; diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx index 27f790d..fee6313 100644 --- a/frontend/src/components/TrainingUnitSectionsEditor.jsx +++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx @@ -10,6 +10,9 @@ import { const DND_TU_ITEM = 'application/x-shinkan-training-unit-item' const DND_TU_SECTION = 'application/x-shinkan-training-section-v1' +/** Optische Trennlinie: wird als normale Zwischen-Anmerkung gespeichert (Inhalt nur dieser Marker). */ +const SECTION_INSERT_SEPARATOR_BODY = '---' + function normalizedPlanningModuleChainId(raw) { if (raw == null || raw === '') return null const n = typeof raw === 'number' ? raw : Number(raw) @@ -48,6 +51,7 @@ export default function TrainingUnitSectionsEditor({ sections, onSectionsChange, onRequestExercisePick, + onRequestTrainingModulePick, onPeekExercise, showExecutionExtras = false, heading = 'Abschnitte & Übungen', @@ -58,6 +62,8 @@ export default function TrainingUnitSectionsEditor({ enableSectionDragReorder = true, slotIndex = null, onMoveSectionsAcrossSlots = null, + /** Dünnes „+“ zwischen Einträge: Popup für Typ (Übung, Modul, …) */ + betweenInsertMenus = true, }) { const ensure = (prev) => prev && prev.length ? prev : [defaultSection()] @@ -99,16 +105,32 @@ export default function TrainingUnitSectionsEditor({ }) } + const insertItemAt = useCallback( + (sIdx, beforeIx, row) => { + patch((prev) => + prev.map((s, i) => { + if (i !== sIdx) return s + const items = [...(s.items || [])] + const ix = Math.max( + 0, + Math.min(Number(beforeIx) || 0, items.length) + ) + items.splice(ix, 0, row) + return { ...s, items } + }) + ) + }, + [patch] + ) + const addItem = (sIdx, kind) => { patch((prev) => - prev.map((s, i) => - i !== sIdx - ? s - : { - ...s, - items: [...(s.items || []), kind === 'note' ? noteRow() : exerciseRow()], - } - ) + prev.map((s, i) => { + if (i !== sIdx) return s + const items = [...(s.items || [])] + items.push(kind === 'note' ? noteRow() : exerciseRow()) + return { ...s, items } + }) ) } @@ -149,6 +171,8 @@ export default function TrainingUnitSectionsEditor({ } const [textEdit, setTextEdit] = useState(null) + /** { sIdx: number, beforeIx: number } – Einfüge-Popup („+“ zwischen Zeilen) */ + const [insertChooser, setInsertChooser] = useState(null) const [draggingPos, setDraggingPos] = useState(null) const [dropTargetPos, setDropTargetPos] = useState(null) @@ -164,6 +188,20 @@ export default function TrainingUnitSectionsEditor({ return () => window.removeEventListener('keydown', onKey) }, [textEdit]) + useEffect(() => { + if (!insertChooser) return + const onKey = (e) => { + if (e.key === 'Escape') setInsertChooser(null) + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [insertChooser]) + + const closeInsertChooser = useCallback(() => setInsertChooser(null), []) + + const insertSlotKeyPrefix = + slotIndex !== null && slotIndex !== undefined ? `sl${slotIndex}-` : '' + const clearSectionDnD = () => setDropSectionBand(null) const onSectionDragStart = (e, sIdx) => { @@ -351,6 +389,29 @@ export default function TrainingUnitSectionsEditor({ setTextEdit(null) } + const renderBetweenInsertBand = (sIdx, beforeIx, itemCount) => { + const posLabel = + beforeIx === 0 + ? 'vor dem ersten Eintrag' + : beforeIx >= itemCount + ? 'am Ende des Abschnitts' + : `vor Eintrag ${beforeIx + 1}` + return ( +
+ +
+ ) + } + const list = ensure(sections) return ( @@ -506,6 +567,8 @@ export default function TrainingUnitSectionsEditor({

)} + {betweenInsertMenus ? renderBetweenInsertBand(sIdx, 0, itemCount) : null} + {(sec.items || []).map((it, iIdx) => { const dropHere = enableItemDragReorder && @@ -536,10 +599,11 @@ export default function TrainingUnitSectionsEditor({ (curMn != null ? `Modul #${curMn}` : '') if (it.item_type === 'note') { + const isSepLine = (it.note_body || '').trim() === SECTION_INSERT_SEPARATOR_BODY const notePv = truncatePreview(it.note_body || '', 260) - const noteHasText = Boolean((it.note_body || '').trim()) + const noteHasText = Boolean((it.note_body || '').trim()) && !isSepLine return ( - + {showModuleBand ? (
) : null} -
+
{enableItemDragReorder ? (
- Zwischen-Anmerkung -

- {noteHasText ? notePv : '—'} -

+ + {isSepLine ? 'Trennung' : 'Zwischen-Anmerkung'} + + {isSepLine ? ( +
+ ) : ( +

+ {noteHasText ? notePv : '—'} +

+ )}
+ {betweenInsertMenus ? renderBetweenInsertBand(sIdx, iIdx + 1, itemCount) : null} ) } @@ -632,7 +713,7 @@ export default function TrainingUnitSectionsEditor({ : Number(it.exercise_variant_id) return ( - + {showModuleBand ? (
) : null}
+ {betweenInsertMenus ? renderBetweenInsertBand(sIdx, iIdx + 1, itemCount) : null}
) })} @@ -837,21 +919,30 @@ export default function TrainingUnitSectionsEditor({ /> ) : null} -
- - +
+ {betweenInsertMenus ? ( +

+ Über die +-Zeilen zwischen den Einträgen fügst du an der gewünschten Stelle Inhalte ein. Reihenfolge + weiter per Ziehen oder den Pfeiltasten ändern. +

+ ) : ( +
+ + +
+ )}
@@ -889,6 +980,91 @@ export default function TrainingUnitSectionsEditor({ + Abschnitt hinzufügen + {insertChooser ? ( +
{ + if (e.target === e.currentTarget) closeInsertChooser() + }} + > +
e.stopPropagation()} + > +

+ An dieser Stelle einfügen +

+

+ Die neue Zeile erscheint genau hier; Reihenfolge kannst du wie gewohnt per Ziehen oder Pfeilen + ändern. +

+
+ + {onRequestTrainingModulePick ? ( + + ) : null} + + + +
+
+
+ ) : null} + {textEdit ? (
+ onRequestExercisePick={({ sectionIndex, itemIndex, insertBeforeIndex }) => setSectionPickerCtx({ slotIdx: si, sectionIndex, itemIndex: typeof itemIndex === 'number' ? itemIndex : undefined, + insertBeforeIndex: + typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex) + ? insertBeforeIndex + : undefined, }) } onPeekExercise={(id, variantId) => @@ -1096,7 +1101,7 @@ export default function TrainingFrameworkProgramEditPage() { if (row) rows.push(row) } if (!rows.length) return - const { slotIdx, sectionIndex: sIdx, itemIndex: iIdx } = sectionPickerCtx + const { slotIdx, sectionIndex: sIdx, itemIndex: iIdx, insertBeforeIndex } = sectionPickerCtx setForm((prev) => ({ ...prev, slots: prev.slots.map((sl, ii) => { @@ -1121,7 +1126,13 @@ export default function TrainingFrameworkProgramEditPage() { if (tail.length) items.splice(iIdx + 1, 0, ...tail) return { ...sec, items } } - return { ...sec, items: [...items, ...rows] } + const rawAt = + typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex) + ? insertBeforeIndex + : items.length + const at = Math.max(0, Math.min(rawAt, items.length)) + items.splice(at, 0, ...rows) + return { ...sec, items } }), } }), diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx index a4e97a5..5263c2b 100644 --- a/frontend/src/pages/TrainingPlanningPage.jsx +++ b/frontend/src/pages/TrainingPlanningPage.jsx @@ -150,7 +150,7 @@ function TrainingPlanningPage() { const [moduleApplyList, setModuleApplyList] = useState([]) const [moduleApplyModuleId, setModuleApplyModuleId] = useState('') const [moduleApplySectionIx, setModuleApplySectionIx] = useState(0) - const [moduleApplyInsertSlot, setModuleApplyInsertSlot] = useState('__end__') + const [moduleApplyInsertSlot, setModuleApplyInsertSlot] = useState('before:0') const [moduleApplyErr, setModuleApplyErr] = useState('') const [startDate, setStartDate] = useState(today) @@ -684,10 +684,27 @@ function TrainingPlanningPage() { } } - const openModuleApplyModal = useCallback(async () => { + const openModuleApplyModal = useCallback(async (placement) => { setModuleApplyErr('') - setModuleApplySectionIx(0) - setModuleApplyInsertSlot('__end__') + const secs = planningFormRef.current?.sections ?? [] + let secIx = 0 + let before = 0 + if (secs.length) { + if (placement && typeof placement.sectionIndex === 'number') { + secIx = Math.min(Math.max(0, placement.sectionIndex), secs.length - 1) + const items = Array.isArray(secs[secIx]?.items) ? secs[secIx].items : [] + const cap = items.length + if (typeof placement.insertBeforeIndex === 'number' && Number.isFinite(placement.insertBeforeIndex)) { + before = Math.min(Math.max(0, placement.insertBeforeIndex), cap) + } else before = cap + } else { + const items = Array.isArray(secs[0]?.items) ? secs[0].items : [] + before = items.length + secIx = 0 + } + } + setModuleApplySectionIx(secIx) + setModuleApplyInsertSlot(`before:${before}`) setModuleApplyOpen(true) try { const list = await api.listTrainingModules() @@ -716,13 +733,13 @@ function TrainingPlanningPage() { } if (secIx < 0 || secIx >= baseSections.length) secIx = 0 - let insertBefore = null - if (moduleApplyInsertSlot === '__end__') insertBefore = 'end' - else if (moduleApplyInsertSlot === '__start__') insertBefore = 'start' - else if (typeof moduleApplyInsertSlot === 'string' && moduleApplyInsertSlot.startsWith('before:')) { + const secItems = Array.isArray(baseSections[secIx]?.items) ? baseSections[secIx].items : [] + const itemCap = secItems.length + let insertBefore = itemCap + if (typeof moduleApplyInsertSlot === 'string' && moduleApplyInsertSlot.startsWith('before:')) { const zi = parseInt(moduleApplyInsertSlot.slice('before:'.length), 10) - insertBefore = Number.isFinite(zi) ? zi : 'end' - } else insertBefore = 'end' + if (Number.isFinite(zi)) insertBefore = Math.min(Math.max(0, zi), itemCap) + } setModuleApplyBusy(true) setModuleApplyErr('') @@ -742,7 +759,7 @@ function TrainingPlanningPage() { } finally { setModuleApplyBusy(false) } - }, [moduleApplyModuleId, moduleApplySectionIx, moduleApplyInsertSlot, formData.sections]) + }, [moduleApplyModuleId, moduleApplySectionIx, moduleApplyInsertSlot]) const handleTakeLead = async (unit) => { if (!user?.id) return @@ -1955,8 +1972,11 @@ function TrainingPlanningPage() { className="form-input" value={String(moduleApplySectionIx)} onChange={(e) => { - setModuleApplySectionIx(parseInt(e.target.value, 10)) - setModuleApplyInsertSlot('__end__') + const newIx = parseInt(e.target.value, 10) + setModuleApplySectionIx(newIx) + const secsNow = planningFormRef.current?.sections ?? [] + const len = Array.isArray(secsNow[newIx]?.items) ? secsNow[newIx].items.length : 0 + setModuleApplyInsertSlot(`before:${len}`) }} disabled={moduleApplyBusy || !formData.sections?.length} > @@ -1976,8 +1996,10 @@ function TrainingPlanningPage() { onChange={(e) => setModuleApplyInsertSlot(e.target.value)} disabled={moduleApplyBusy || !(formData.sections?.length > 0)} > - - + + {moduleApplyTargetItems.map((row, xi) => { const labelPart = row.item_type === 'note' @@ -2526,14 +2548,6 @@ function TrainingPlanningPage() { - } sections={formData.sections} @@ -2544,10 +2558,17 @@ function TrainingPlanningPage() { sections: updater(prev.sections), })) } - onRequestExercisePick={({ sectionIndex, itemIndex }) => { + onRequestTrainingModulePick={(ctx) => { + void openModuleApplyModal(ctx) + }} + onRequestExercisePick={({ sectionIndex, itemIndex, insertBeforeIndex }) => { setExercisePickerTarget({ sIdx: sectionIndex, iIdx: typeof itemIndex === 'number' ? itemIndex : undefined, + insertBeforeIndex: + typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex) + ? insertBeforeIndex + : undefined, }) setExercisePickerOpen(true) }} @@ -2700,7 +2721,7 @@ function TrainingPlanningPage() { if (row) rows.push(row) } if (!rows.length) return - const { sIdx, iIdx } = exercisePickerTarget + const { sIdx, iIdx, insertBeforeIndex } = exercisePickerTarget setFormData((prev) => ({ ...prev, sections: prev.sections.map((s, si) => { @@ -2720,7 +2741,13 @@ function TrainingPlanningPage() { if (tail.length) items.splice(iIdx + 1, 0, ...tail) return { ...s, items } } - return { ...s, items: [...items, ...rows] } + const rawAt = + typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex) + ? insertBeforeIndex + : items.length + const at = Math.max(0, Math.min(rawAt, items.length)) + items.splice(at, 0, ...rows) + return { ...s, items } }), })) setExercisePickerOpen(false)