diff --git a/frontend/src/app.css b/frontend/src/app.css index 70ef2a0..7ca5e2a 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -3159,6 +3159,309 @@ a.analysis-split__nav-item { color: var(--accent-text, #fff); } +/* ——— Trainings‑Einheit: Übungszeilen schlank + DnD ——— */ +.training-unit-sections-editor--wide .tu-ex-variant-select--wide { + max-width: 100%; +} + +.tu-item-row { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 6px 8px; + margin-top: 0.5rem; + padding: 0.4rem 6px 0.45rem; + border-top: 1px solid rgba(0, 0, 0, 0.06); + min-width: 0; + transition: outline 0.1s ease; + border-radius: 8px; +} + +.tu-item-row--drop-target { + outline: 2px dashed var(--accent); + outline-offset: 1px; +} + +.tu-item-row--dragging { + opacity: 0.52; +} + +.tu-row-grip { + flex: 0 0 auto; + display: inline-flex; + align-items: flex-start; + padding: 4px 2px; + margin-top: 2px; + border-radius: 6px; + color: var(--text3); + cursor: grab; + user-select: none; + touch-action: none; +} + +.tu-row-grip:active { + cursor: grabbing; +} + +.tu-item-row__nudge { + flex: 0 0 auto; + display: flex; + flex-direction: column; + gap: 0; + padding-top: 4px; +} + +.tu-item-row__nudge button { + padding: 0 5px; + line-height: 1.2; + font-size: 11px; + min-height: 20px; + border: none; + background: transparent; + color: var(--text2); + border-radius: 4px; +} + +.tu-item-row__nudge button:disabled { + opacity: 0.3; +} + +.tu-item-row__nudge button:not(:disabled):hover { + background: rgba(0, 0, 0, 0.06); +} + +.tu-item-row__mainline { + display: flex; + flex: 1; + flex-wrap: nowrap; + gap: 8px; + align-items: flex-start; + min-width: 0; + width: 100%; +} + +.tu-item-row--note .tu-icon-btn, +.tu-item-row--note .tu-item-row__remove { + align-self: center; +} + +.tu-item-row--note .tu-item-row__body--note { + flex: 1; + min-width: 0; +} + +.tu-item-row--note { + flex-wrap: nowrap; +} + +.tu-item-row__body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 5px; +} + +.tu-item-row__meta-label { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text3); +} + +.tu-item-row__preview { + margin: 0; + font-size: 0.86rem; + line-height: 1.35; + color: var(--text1); + word-break: break-word; +} + +.tu-item-row__preview--clamp { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; +} + +.tu-item-row__preview--empty { + color: var(--text3); +} + +.tu-icon-btn { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px; + border-radius: 8px; + border: 1px solid var(--border2); + background: var(--surface); + color: var(--text2); + cursor: pointer; + line-height: 0; +} + +.tu-icon-btn:hover { + border-color: var(--accent); + color: var(--accent-dark); +} + +.tu-item-row__remove { + flex: 0 0 auto; + padding: 5px 10px; + min-height: 32px; + font-size: 12px; + background: var(--danger); + color: #fff; + border: none; + border-radius: 7px; + cursor: pointer; + line-height: 1; +} + +.tu-item-row__remove:hover { + filter: brightness(1.05); +} + +.tu-item-row__side { + flex: 0 0 auto; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 5px; +} + +.tu-ex-duration { + margin: 0; + width: 4.75rem; + font-size: 0.84rem; +} + +.tu-ex-title-line { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px 12px; +} + +.tu-ex-title { + font-size: 0.95rem; + font-weight: 700; + line-height: 1.35; + flex: 1 1 220px; + min-width: 0; + word-break: break-word; +} + +.tu-ex-title-placeholder { + font-size: 0.9rem; + color: var(--text3); + font-style: italic; +} + +.tu-ex-inline-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.tu-ex-meta-line { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 14px; +} + +.tu-ex-variant-select { + margin: 0; + flex: 0 1 min(220px, 100%); + font-size: 0.82rem; + min-width: 0; +} + +.training-unit-sections-editor--wide .tu-ex-variant-select--wide { + flex-basis: min(320px, 100%); +} + +.tu-ex-annot { + flex: 1 1 140px; + min-width: 0; + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; +} + +.tu-ex-annot__text { + flex: 1; + min-width: 0; + font-size: 0.82rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tu-ex-run-block { + display: block; + width: 100%; + margin-top: 10px; + font-size: 0.78rem; +} + +.tu-ex-run-block__controls { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 5px; +} + +.tu-ex-run-block__controls .form-input:first-of-type { + max-width: 120px; +} + +.tu-textedit-backdrop { + position: fixed; + inset: 0; + z-index: 10060; + background: rgba(15, 23, 42, 0.42); + display: flex; + align-items: flex-start; + justify-content: center; + padding: 8vh 14px 40px; +} + +.tu-textedit-panel { + width: 100%; + max-width: 480px; + background: var(--surface); + border-radius: 14px; + border: 1px solid var(--border); + padding: 1rem 1.15rem 1.1rem; + box-shadow: 0 22px 52px rgba(0, 0, 0, 0.22); +} + +.tu-textedit-title { + margin: 0 0 0.65rem; + font-size: 1.03rem; +} + +.tu-textedit-textarea { + width: 100%; + resize: vertical; + min-height: 100px; + margin-bottom: 0.85rem; +} + +.tu-textedit-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: flex-end; +} + .framework-slot-card__head { display: flex; flex-direction: row; diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx index 5e82b06..3faa629 100644 --- a/frontend/src/components/TrainingUnitSectionsEditor.jsx +++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx @@ -1,4 +1,5 @@ -import React, { useCallback } from 'react' +import React, { useCallback, useEffect, useState } from 'react' +import { GripVertical, Pencil } from 'lucide-react' import { defaultSection, exerciseRow, @@ -6,6 +7,14 @@ import { sectionPlannedMinutes, } from '../utils/trainingUnitSectionsForm' +const DND_TU_ITEM = 'application/x-shinkan-training-unit-item' + +function truncatePreview(text, max = 160) { + const t = (text || '').replace(/\s+/g, ' ').trim() + if (t.length <= max) return t + return `${t.slice(0, max - 1)}…` +} + /** * @param {(updater: (prev: Array) => Array) => void} props.onSectionsChange — wie React setState */ @@ -18,6 +27,7 @@ export default function TrainingUnitSectionsEditor({ heading = 'Abschnitte & Übungen', hideHeading = false, wideExerciseGrid = false, + enableItemDragReorder = true, }) { const ensure = (prev) => prev && prev.length ? prev : [defaultSection()] @@ -105,10 +115,142 @@ export default function TrainingUnitSectionsEditor({ ) } + const [textEdit, setTextEdit] = useState(null) + const [draggingPos, setDraggingPos] = useState(null) + const [dropTargetPos, setDropTargetPos] = useState(null) + + useEffect(() => { + if (!textEdit) return + const onKey = (e) => { + if (e.key === 'Escape') setTextEdit(null) + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [textEdit]) + + 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 + 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 ? (

{heading}

) : null} @@ -184,136 +326,185 @@ export default function TrainingUnitSectionsEditor({

)} - {(sec.items || []).map((it, iIdx) => - it.item_type === 'note' ? ( -
-
- Zwischen-Anmerkung -
-
-
+ {(sec.items || []).map((it, iIdx) => { + const dropHere = + enableItemDragReorder && + dropTargetPos?.sIdx === sIdx && + dropTargetPos?.iIdx === iIdx + const dragHere = + enableItemDragReorder && + draggingPos?.sIdx === sIdx && + draggingPos?.iIdx === iIdx + const rowCommon = + 'tu-item-row' + + (dropHere ? ' tu-item-row--drop-target' : '') + + (dragHere ? ' tu-item-row--dragging' : '') + + const dndRowProps = enableItemDragReorder + ? { + onDragOver: (e) => onItemDragOverRow(e, sIdx, iIdx), + onDrop: (e) => onItemDropRow(e, sIdx, iIdx), + } + : {} + + if (it.item_type === 'note') { + const notePv = truncatePreview(it.note_body || '', 260) + const noteHasText = Boolean((it.note_body || '').trim()) + return ( +
+ {enableItemDragReorder ? ( + onItemDragStart(e, sIdx, iIdx)} + onDragEnd={clearDragChrome} + role="button" + tabIndex={0} + aria-label="Eintrag ziehen" + > + + + ) : null} +
-