All checks were successful
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 56s
- Bumped app version to 0.8.100, reflecting recent updates. - Improved validation logic for combination exercises in the backend, ensuring proper handling of exercise variants. - Enhanced frontend components, including the ExercisePickerModal, to support filtering and displaying combination exercises. - Updated API payloads and utility functions to accommodate new exercise types and their properties. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1307 lines
48 KiB
JavaScript
1307 lines
48 KiB
JavaScript
import React, { Fragment, useCallback, useEffect, useState } from 'react'
|
||
import { GripVertical, Pencil } from 'lucide-react'
|
||
import {
|
||
defaultSection,
|
||
exerciseRow,
|
||
noteRow,
|
||
sectionPlannedMinutes,
|
||
} from '../utils/trainingUnitSectionsForm'
|
||
import { isCompactTagLegendMode } from '../config/planningModuleUx'
|
||
import { useAuth } from '../context/AuthContext'
|
||
|
||
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)
|
||
return Number.isFinite(n) && n >= 1 ? n : null
|
||
}
|
||
|
||
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)}…`
|
||
}
|
||
|
||
/** Liest den zusammenhängenden Lauf eines Moduls im Abschnitt (ab erstem Item mit dieser Herkunfts-ID). */
|
||
function gatherPlanningModuleOutline(items, startIdx, moduleId) {
|
||
const exercises = []
|
||
let notes = 0
|
||
for (let j = startIdx; j < (items?.length ?? 0); j++) {
|
||
const row = items[j]
|
||
if (normalizedPlanningModuleChainId(row.source_training_module_id) !== moduleId) break
|
||
if (row.item_type === 'note') {
|
||
const bod = (row.note_body || '').trim()
|
||
if (bod === SECTION_INSERT_SEPARATOR_BODY) continue
|
||
notes += 1
|
||
continue
|
||
}
|
||
const t =
|
||
(row.exercise_title || '').trim() ||
|
||
(row.exercise_id ? `Übung #${row.exercise_id}` : 'Übung')
|
||
exercises.push(t)
|
||
}
|
||
return { exercises, notes }
|
||
}
|
||
|
||
const MODULE_OUTLINE_PREVIEW_MAX = 8
|
||
|
||
/** Stabile Farbzurodnung aus Modul-ID (nur Darstellung). */
|
||
function planningModulePalette(moduleId) {
|
||
const id = normalizedPlanningModuleChainId(moduleId)
|
||
const n = id != null && id >= 1 ? Math.floor(Number(id)) : 1
|
||
const golden = ((n >>> 0) * 2654435761 + n * 73856093) >>> 0
|
||
const h = golden % 360
|
||
const border = `hsl(${h} 52% 36%)`
|
||
const soft = `hsl(${h} 42% 94%)`
|
||
return { border, soft, hue: h }
|
||
}
|
||
|
||
function PlanningModuleRowTag({ moduleId, title }) {
|
||
const p = planningModulePalette(moduleId)
|
||
const lbl = truncatePreview(title || `Modul #${moduleId}`, 34).trim()
|
||
const fullTitle = ((title || '').trim() || `Modul #${moduleId}`).trim()
|
||
return (
|
||
<span
|
||
className="tu-planning-mod-tag"
|
||
style={{ borderColor: p.border, backgroundColor: p.soft }}
|
||
title={`${fullTitle} (Bibliotheks-ID ${moduleId})`}
|
||
>
|
||
<span className="tu-planning-mod-tag__dot" style={{ background: p.border }} aria-hidden />
|
||
<span className="tu-planning-mod-tag__text">Aus Modul: {lbl}</span>
|
||
</span>
|
||
)
|
||
}
|
||
|
||
/** Eindeutige Module im Abschnitt mit Zählerständen für die Legende. */
|
||
function sectionModuleLegendModel(items) {
|
||
const map = new Map()
|
||
for (const row of items || []) {
|
||
const id = normalizedPlanningModuleChainId(row.source_training_module_id)
|
||
if (id == null) continue
|
||
if (!map.has(id)) {
|
||
map.set(id, {
|
||
id,
|
||
title: (((row.source_module_title || '').trim() || '') || `Modul #${id}`).trim(),
|
||
exercises: 0,
|
||
notes: 0,
|
||
})
|
||
}
|
||
const agg = map.get(id)
|
||
if ((row.item_type || '') === 'note') {
|
||
const bod = ((row.note_body || '').trim() || '').trim()
|
||
if (bod === SECTION_INSERT_SEPARATOR_BODY) continue
|
||
agg.notes += 1
|
||
} else {
|
||
agg.exercises += 1
|
||
}
|
||
}
|
||
return [...map.values()].sort((a, b) => a.id - b.id)
|
||
}
|
||
|
||
function renderModulePlanningHead(modBandTitle, modOutline, showModuleBand) {
|
||
if (!showModuleBand || !modOutline) return null
|
||
return (
|
||
<div className="tu-module-bundle-head" role="group" aria-label={`Modul ${modBandTitle}`}>
|
||
<div className="tu-module-bundle-head__stripe" aria-hidden />
|
||
<div className="tu-module-bundle-head__main">
|
||
<span className="tu-module-bundle-head__kicker">Aus Modul</span>
|
||
<strong className="tu-module-bundle-head__title">{modBandTitle}</strong>
|
||
{modOutline.exercises.length === 0 && modOutline.notes === 0 ? (
|
||
<p className="tu-module-bundle-head__empty">Ohne strukturierten Inhalt angezeigt.</p>
|
||
) : (
|
||
<ol className="tu-module-bundle-head__list" start={1}>
|
||
{modOutline.exercises.slice(0, MODULE_OUTLINE_PREVIEW_MAX).map((tx, ox) => (
|
||
<li key={`mo-li-${modBandTitle}-${ox}`}>{tx}</li>
|
||
))}
|
||
</ol>
|
||
)}
|
||
{modOutline.exercises.length > MODULE_OUTLINE_PREVIEW_MAX ? (
|
||
<p className="tu-module-bundle-head__more">
|
||
… und noch {modOutline.exercises.length - MODULE_OUTLINE_PREVIEW_MAX}{' '}
|
||
{modOutline.exercises.length - MODULE_OUTLINE_PREVIEW_MAX === 1 ? 'Übung' : 'Übungen'}
|
||
</p>
|
||
) : null}
|
||
{modOutline.notes > 0 ? (
|
||
<p className="tu-module-bundle-head__meta">
|
||
sowie {modOutline.notes}{' '}
|
||
{modOutline.notes === 1 ? 'Zwischen-Hinweis' : 'Zwischen-Hinweise'}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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,
|
||
onRequestTrainingModulePick,
|
||
onPeekExercise,
|
||
showExecutionExtras = false,
|
||
heading = 'Abschnitte & Übungen',
|
||
hideHeading = false,
|
||
headingAccessory = null,
|
||
wideExerciseGrid = false,
|
||
enableItemDragReorder = true,
|
||
enableSectionDragReorder = true,
|
||
slotIndex = null,
|
||
onMoveSectionsAcrossSlots = null,
|
||
/** Dünnes „+“ zwischen Einträge: Popup für Typ (Übung, Modul, …) */
|
||
betweenInsertMenus = true,
|
||
}) {
|
||
const { user } = useAuth()
|
||
const planningCompactLegend = isCompactTagLegendMode(
|
||
user?.training_planning_prefs?.module_display_mode
|
||
)
|
||
|
||
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 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) => {
|
||
if (i !== sIdx) return s
|
||
const items = [...(s.items || [])]
|
||
items.push(kind === 'note' ? noteRow() : exerciseRow())
|
||
return { ...s, items }
|
||
})
|
||
)
|
||
}
|
||
|
||
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)
|
||
/** { sIdx: number, beforeIx: number } – Einfüge-Popup („+“ zwischen Zeilen) */
|
||
const [insertChooser, setInsertChooser] = 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])
|
||
|
||
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) => {
|
||
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 renderBetweenInsertBand = (sIdx, beforeIx, itemCount) => {
|
||
const posLabel =
|
||
beforeIx === 0
|
||
? 'vor dem ersten Eintrag'
|
||
: beforeIx >= itemCount
|
||
? 'am Ende des Abschnitts'
|
||
: `vor Eintrag ${beforeIx + 1}`
|
||
return (
|
||
<div className="tu-insert-slot">
|
||
<button
|
||
type="button"
|
||
className="tu-insert-slot__btn"
|
||
aria-haspopup="dialog"
|
||
aria-label={`Inhalt einfügen (${posLabel})`}
|
||
title={`Hier einfügen (${posLabel})`}
|
||
onClick={() => setInsertChooser({ sIdx, beforeIx })}
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const list = ensure(sections)
|
||
|
||
return (
|
||
<div
|
||
className={
|
||
'training-unit-sections-editor' +
|
||
(wideExerciseGrid ? ' training-unit-sections-editor--wide' : '')
|
||
}
|
||
>
|
||
{(!hideHeading || headingAccessory) ? (
|
||
<div
|
||
className="tu-editor-heading-toolbar"
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
gap: '10px',
|
||
marginBottom: '0.75rem',
|
||
}}
|
||
>
|
||
{!hideHeading ? (
|
||
<h3 style={{ margin: 0, fontSize: '1rem', flex: '1 1 200px', minWidth: 0 }}>
|
||
{heading}
|
||
</h3>
|
||
) : headingAccessory ? (
|
||
<span style={{ flex: '1 1 auto', minWidth: 0 }} />
|
||
) : null}
|
||
{headingAccessory ? (
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: '8px',
|
||
justifyContent: 'flex-end',
|
||
alignItems: 'center',
|
||
}}
|
||
>
|
||
{headingAccessory}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
{list.map((sec, sIdx) => {
|
||
const planMin = sectionPlannedMinutes(sec)
|
||
const itemCount = sec.items?.length ?? 0
|
||
const moduleLegend = planningCompactLegend ? sectionModuleLegendModel(sec.items) : []
|
||
const bandActiveBefore = (bx) =>
|
||
enableSectionDragReorder &&
|
||
dropSectionBand &&
|
||
dropSectionBand.slot === sectionToSlot &&
|
||
dropSectionBand.beforeIdx === bx
|
||
|
||
return (
|
||
<Fragment key={`secFrag-${sIdx}`}>
|
||
{enableSectionDragReorder ? (
|
||
<div
|
||
className={'tu-section-dropband' + (bandActiveBefore(sIdx) ? ' tu-section-dropband--active' : '')}
|
||
title="Abschnitt hier einfügen"
|
||
onDragOver={(e) => {
|
||
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}
|
||
<div
|
||
className="tu-section-shell"
|
||
style={{
|
||
marginBottom: '1rem',
|
||
padding: '0.75rem',
|
||
background: 'var(--surface2)',
|
||
borderRadius: '10px',
|
||
border: '1px solid var(--border, rgba(0,0,0,0.08))',
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: '0.5rem',
|
||
marginBottom: '0.5rem',
|
||
alignItems: 'flex-start',
|
||
}}
|
||
>
|
||
{enableSectionDragReorder ? (
|
||
<span
|
||
className="tu-sec-drag-grip"
|
||
draggable
|
||
onDragStart={(e) => onSectionDragStart(e, sIdx)}
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label="Abschnitt ziehen"
|
||
title="Abschnitt ziehen"
|
||
>
|
||
<GripVertical size={16} strokeWidth={2} aria-hidden />
|
||
</span>
|
||
) : null}
|
||
<input
|
||
className="form-input"
|
||
style={{ flex: '2 1 180px', marginBottom: 0 }}
|
||
value={sec.title}
|
||
onChange={(e) => updateSectionField(sIdx, 'title', e.target.value)}
|
||
placeholder="Abschnittstitel (z. B. Aufwärmen)"
|
||
/>
|
||
<div style={{ display: 'flex', gap: '4px', alignSelf: 'center' }}>
|
||
<button
|
||
type="button"
|
||
aria-label="Abschnitt hoch"
|
||
onClick={() => moveSection(sIdx, -1)}
|
||
disabled={sIdx === 0}
|
||
style={{ padding: '4px 10px', opacity: sIdx === 0 ? 0.35 : 1 }}
|
||
>
|
||
▲
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label="Abschnitt runter"
|
||
onClick={() => moveSection(sIdx, 1)}
|
||
disabled={sIdx === list.length - 1}
|
||
style={{
|
||
padding: '4px 10px',
|
||
opacity: sIdx === list.length - 1 ? 0.35 : 1,
|
||
}}
|
||
>
|
||
▼
|
||
</button>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={() => removeSection(sIdx)}
|
||
>
|
||
Abschnitt entfernen
|
||
</button>
|
||
</div>
|
||
<textarea
|
||
className="form-input"
|
||
rows={2}
|
||
value={sec.guidance_notes}
|
||
onChange={(e) =>
|
||
updateSectionField(sIdx, 'guidance_notes', e.target.value)
|
||
}
|
||
placeholder="Hinweise zum Abschnitt (Material, Zeit, Zielrichtung …)"
|
||
/>
|
||
{planMin > 0 && (
|
||
<p style={{ fontSize: '0.78rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
|
||
Geplant in diesem Abschnitt: ca. {planMin} Min. (Übungen)
|
||
</p>
|
||
)}
|
||
|
||
{betweenInsertMenus ? renderBetweenInsertBand(sIdx, 0, itemCount) : null}
|
||
|
||
{(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
|
||
? {
|
||
onDragOverCapture: (ev) => onItemDragOverRow(ev, sIdx, iIdx),
|
||
onDrop: (ev) => onItemDropRow(ev, sIdx, iIdx),
|
||
}
|
||
: {}
|
||
|
||
const prevIt = iIdx > 0 ? sec.items[iIdx - 1] : null
|
||
const curMn = normalizedPlanningModuleChainId(it.source_training_module_id)
|
||
const showModuleBand =
|
||
curMn != null && curMn !== normalizedPlanningModuleChainId(prevIt?.source_training_module_id)
|
||
const modBandTitle =
|
||
(it.source_module_title || '').trim() ||
|
||
(curMn != null ? `Modul #${curMn}` : '')
|
||
|
||
const modOutline =
|
||
!planningCompactLegend &&
|
||
showModuleBand &&
|
||
curMn != null
|
||
? gatherPlanningModuleOutline(sec.items, iIdx, curMn)
|
||
: null
|
||
const fromModClass =
|
||
curMn != null
|
||
? planningCompactLegend
|
||
? ' tu-item-row--from-module-soft'
|
||
: ' tu-item-row--from-module'
|
||
: ''
|
||
const modBorderVarStyle =
|
||
planningCompactLegend && curMn != null
|
||
? { '--tu-mod-border': planningModulePalette(curMn).border }
|
||
: undefined
|
||
|
||
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()) && !isSepLine
|
||
return (
|
||
<Fragment key={`${insertSlotKeyPrefix}sec-${sIdx}-blk-${iIdx}`}>
|
||
{!planningCompactLegend &&
|
||
renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
|
||
<div
|
||
className={
|
||
`${rowCommon} tu-item-row--note` +
|
||
(isSepLine ? ' tu-item-row--separator-note' : '') +
|
||
fromModClass
|
||
}
|
||
{...dndRowProps}
|
||
style={modBorderVarStyle}
|
||
>
|
||
{enableItemDragReorder ? (
|
||
<span
|
||
className="tu-row-grip"
|
||
draggable
|
||
onDragStart={(e) => onItemDragStart(e, sIdx, iIdx)}
|
||
onDragEnd={clearDragChrome}
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label="Eintrag ziehen"
|
||
>
|
||
<GripVertical size={15} strokeWidth={2} aria-hidden />
|
||
</span>
|
||
) : null}
|
||
<div className="tu-item-row__nudge">
|
||
<button
|
||
type="button"
|
||
aria-label="Eintrag nach oben"
|
||
onClick={() => moveItem(sIdx, iIdx, -1)}
|
||
disabled={iIdx === 0}
|
||
>
|
||
▲
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label="Eintrag nach unten"
|
||
onClick={() => moveItem(sIdx, iIdx, 1)}
|
||
disabled={iIdx === sec.items.length - 1}
|
||
>
|
||
▼
|
||
</button>
|
||
</div>
|
||
<div className="tu-item-row__body tu-item-row__body--note">
|
||
{!isSepLine && planningCompactLegend && curMn ? (
|
||
<PlanningModuleRowTag moduleId={curMn} title={modBandTitle} />
|
||
) : null}
|
||
<span className="tu-item-row__meta-label">
|
||
{isSepLine ? 'Trennung' : 'Zwischen-Anmerkung'}
|
||
</span>
|
||
{isSepLine ? (
|
||
<div
|
||
className="tu-item-row__separator-line"
|
||
role="separator"
|
||
aria-label="Trennlinie im Ablauf"
|
||
/>
|
||
) : (
|
||
<p
|
||
className={`tu-item-row__preview tu-item-row__preview--clamp${noteHasText ? '' : ' tu-item-row__preview--empty'}`}
|
||
title={noteHasText ? (it.note_body || '').trim() : undefined}
|
||
>
|
||
{noteHasText ? notePv : '—'}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="tu-icon-btn"
|
||
title={isSepLine ? 'Trennung bearbeiten' : 'Zwischen-Anmerkung bearbeiten'}
|
||
aria-label={isSepLine ? 'Trennung bearbeiten' : 'Zwischen-Anmerkung bearbeiten'}
|
||
onClick={() =>
|
||
setTextEdit({
|
||
kind: 'zwischen-note',
|
||
sIdx,
|
||
iIdx,
|
||
draft: it.note_body || '',
|
||
})
|
||
}
|
||
>
|
||
<Pencil size={15} strokeWidth={2} aria-hidden />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="tu-item-row__remove"
|
||
title="Entfernen"
|
||
aria-label={isSepLine ? 'Trennung entfernen' : 'Zwischen-Anmerkung entfernen'}
|
||
onClick={() => removeItem(sIdx, iIdx)}
|
||
>
|
||
✗
|
||
</button>
|
||
</div>
|
||
{betweenInsertMenus ? renderBetweenInsertBand(sIdx, iIdx + 1, itemCount) : null}
|
||
</Fragment>
|
||
)
|
||
}
|
||
|
||
const variantOpts = Array.isArray(it.variants) ? it.variants : []
|
||
const exTitle =
|
||
it.exercise_title || (it.exercise_id ? `Übung #${it.exercise_id}` : '')
|
||
const isCombination =
|
||
String(it.exercise_kind || 'simple').toLowerCase().trim() === 'combination'
|
||
const annotPrev = truncatePreview(it.notes || '', 220)
|
||
const annotHasText = Boolean((it.notes || '').trim())
|
||
const hasVariants = !isCombination && variantOpts.length > 0 && it.exercise_id
|
||
const variantIdPeek =
|
||
it.exercise_variant_id === '' || it.exercise_variant_id == null
|
||
? undefined
|
||
: Number(it.exercise_variant_id)
|
||
|
||
return (
|
||
<Fragment key={`${insertSlotKeyPrefix}sec-${sIdx}-blk-${iIdx}`}>
|
||
{!planningCompactLegend &&
|
||
renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
|
||
<div
|
||
className={`${rowCommon} tu-item-row--exercise${fromModClass}`}
|
||
{...dndRowProps}
|
||
style={modBorderVarStyle}
|
||
>
|
||
<div className="tu-item-row__mainline">
|
||
{enableItemDragReorder ? (
|
||
<span
|
||
className="tu-row-grip"
|
||
draggable
|
||
onDragStart={(e) => onItemDragStart(e, sIdx, iIdx)}
|
||
onDragEnd={clearDragChrome}
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label="Eintrag ziehen"
|
||
>
|
||
<GripVertical size={15} strokeWidth={2} aria-hidden />
|
||
</span>
|
||
) : null}
|
||
<div className="tu-item-row__nudge">
|
||
<button
|
||
type="button"
|
||
aria-label="Eintrag nach oben"
|
||
onClick={() => moveItem(sIdx, iIdx, -1)}
|
||
disabled={iIdx === 0}
|
||
>
|
||
▲
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label="Eintrag nach unten"
|
||
onClick={() => moveItem(sIdx, iIdx, 1)}
|
||
disabled={iIdx === sec.items.length - 1}
|
||
>
|
||
▼
|
||
</button>
|
||
</div>
|
||
<div className="tu-item-row__body tu-item-row__body--exercise">
|
||
<div className="tu-ex-title-line">
|
||
{exTitle ? (
|
||
<strong className="tu-ex-title">{exTitle}</strong>
|
||
) : (
|
||
<span className="tu-ex-title-placeholder">Keine Übung gewählt</span>
|
||
)}
|
||
{isCombination ? (
|
||
<span
|
||
className="exercise-tag"
|
||
style={{
|
||
marginLeft: 8,
|
||
fontSize: '11px',
|
||
alignSelf: 'center',
|
||
background: 'var(--accent-soft)',
|
||
color: 'var(--accent-dark)',
|
||
}}
|
||
>
|
||
Kombination
|
||
</span>
|
||
) : null}
|
||
{planningCompactLegend && curMn ? (
|
||
<PlanningModuleRowTag moduleId={curMn} title={modBandTitle} />
|
||
) : null}
|
||
<span className="tu-ex-inline-actions">
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={() =>
|
||
onRequestExercisePick?.({
|
||
sectionIndex: sIdx,
|
||
itemIndex: iIdx,
|
||
})
|
||
}
|
||
>
|
||
{exTitle ? 'Wechseln' : 'Übung suchen…'}
|
||
</button>
|
||
{it.exercise_id && onPeekExercise ? (
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={() =>
|
||
onPeekExercise(Number(it.exercise_id), variantIdPeek)
|
||
}
|
||
>
|
||
Vorschau
|
||
</button>
|
||
) : null}
|
||
</span>
|
||
</div>
|
||
<div className="tu-ex-meta-line">
|
||
{hasVariants ? (
|
||
<select
|
||
className={`form-input tu-ex-variant-select${
|
||
wideExerciseGrid ? ' tu-ex-variant-select--wide' : ''
|
||
}`}
|
||
value={
|
||
it.exercise_variant_id === '' ||
|
||
it.exercise_variant_id == null
|
||
? ''
|
||
: String(it.exercise_variant_id)
|
||
}
|
||
onChange={(e) => {
|
||
const raw = e.target.value
|
||
updateItem(
|
||
sIdx,
|
||
iIdx,
|
||
'exercise_variant_id',
|
||
raw === '' ? '' : parseInt(raw, 10)
|
||
)
|
||
}}
|
||
title="Übungsvariante"
|
||
>
|
||
<option value="">Stammübung</option>
|
||
{variantOpts.map((v) => (
|
||
<option key={v.id} value={v.id}>
|
||
{v.variant_name || `Variante #${v.id}`}
|
||
</option>
|
||
))}
|
||
</select>
|
||
) : null}
|
||
<div className="tu-ex-annot">
|
||
<span
|
||
className={`tu-item-row__preview tu-ex-annot__text${annotHasText ? '' : ' tu-item-row__preview--empty'}`}
|
||
title={annotHasText ? (it.notes || '').trim() : undefined}
|
||
>
|
||
{annotHasText ? annotPrev : '—'}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="tu-icon-btn"
|
||
title="Anmerkung zur Übung"
|
||
aria-label="Anmerkung zur Übung bearbeiten"
|
||
onClick={() =>
|
||
setTextEdit({
|
||
kind: 'exercise-notes',
|
||
sIdx,
|
||
iIdx,
|
||
draft: it.notes || '',
|
||
})
|
||
}
|
||
>
|
||
<Pencil size={15} strokeWidth={2} aria-hidden />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="tu-item-row__side">
|
||
<input
|
||
type="number"
|
||
className="form-input tu-ex-duration"
|
||
min={1}
|
||
value={it.planned_duration_min}
|
||
onChange={(e) =>
|
||
updateItem(sIdx, iIdx, 'planned_duration_min', e.target.value)
|
||
}
|
||
placeholder="Min"
|
||
title="Geplante Dauer (Minuten)"
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="tu-item-row__remove"
|
||
title="Übung entfernen"
|
||
aria-label="Übung entfernen"
|
||
onClick={() => removeItem(sIdx, iIdx)}
|
||
>
|
||
✗
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{showExecutionExtras ? (
|
||
<div className="tu-ex-debrief">
|
||
<div className="tu-ex-debrief__grow">
|
||
<span className="tu-item-row__meta-label">Abweichungen beim Durchführen</span>
|
||
<textarea
|
||
className="form-input tu-ex-debrief__textarea"
|
||
rows={3}
|
||
value={it.modifications || ''}
|
||
onChange={(e) =>
|
||
updateItem(sIdx, iIdx, 'modifications', e.target.value)
|
||
}
|
||
placeholder="Was lief anders? Anpassungen für spätere Planung…"
|
||
/>
|
||
</div>
|
||
<div className="tu-ex-debrief__ist">
|
||
<span className="tu-item-row__meta-label">Ist (Min)</span>
|
||
<input
|
||
type="number"
|
||
className="form-input tu-ex-duration"
|
||
min={1}
|
||
value={it.actual_duration_min}
|
||
onChange={(e) =>
|
||
updateItem(sIdx, iIdx, 'actual_duration_min', e.target.value)
|
||
}
|
||
placeholder="IST"
|
||
title="Tatsächliche Dauer (Minuten); dieselbe Spaltenbreite wie „Min“ (Plan) oben"
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
{betweenInsertMenus ? renderBetweenInsertBand(sIdx, iIdx + 1, itemCount) : null}
|
||
</Fragment>
|
||
)
|
||
})}
|
||
|
||
{enableItemDragReorder ? (
|
||
<div
|
||
className={`tu-item-append-drop${
|
||
dropTargetPos?.sIdx === sIdx && dropTargetPos?.iIdx === itemCount
|
||
? ' tu-item-append-drop--active'
|
||
: ''
|
||
}`}
|
||
title="Hierhin ziehen, um nach unten einzufügen"
|
||
onDragOverCapture={(e) => onItemDragOverRow(e, sIdx, itemCount)}
|
||
onDrop={(e) => onItemDropRow(e, sIdx, itemCount)}
|
||
/>
|
||
) : null}
|
||
|
||
<div style={{ marginTop: '0.65rem' }}>
|
||
{betweenInsertMenus ? (
|
||
<p style={{ margin: 0, fontSize: '0.8rem', color: 'var(--text3)', lineHeight: 1.45, maxWidth: '42rem' }}>
|
||
Ü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.
|
||
</p>
|
||
) : (
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={() => onRequestExercisePick?.({ sectionIndex: sIdx })}
|
||
>
|
||
+ Übung
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={() => addItem(sIdx, 'note')}
|
||
>
|
||
+ Anmerkung
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{moduleLegend.length ? (
|
||
<div
|
||
className="tu-section-mod-legend"
|
||
aria-label="Liste der eingebundenen Trainingsmodule in diesem Abschnitt"
|
||
>
|
||
<div className="tu-section-mod-legend__caption">Übernommene Module im Abschnitt</div>
|
||
<ul className="tu-section-mod-legend__list">
|
||
{moduleLegend.map((e) => {
|
||
const pal = planningModulePalette(e.id)
|
||
return (
|
||
<li key={`mod-leg-${sIdx}-${e.id}`} className="tu-section-mod-legend__item">
|
||
<span
|
||
className="tu-section-mod-legend__swatch"
|
||
style={{ background: pal.border }}
|
||
title={`Farbe wie an den Zeilen (Modul #${e.id})`}
|
||
aria-hidden
|
||
/>
|
||
<span className="tu-section-mod-legend__text">
|
||
<span className="tu-section-mod-legend__title">
|
||
{(e.title || '').trim() || `Modul #${e.id}`}
|
||
</span>
|
||
<span className="tu-section-mod-legend__meta">
|
||
ID {e.id} · {e.exercises}{' '}
|
||
{e.exercises === 1 ? 'Übung' : 'Übungen'}
|
||
{e.notes > 0 ? (
|
||
<>
|
||
{' '}
|
||
· {e.notes} {e.notes === 1 ? 'Zwischen-Hinweis' : 'Zwischen-Hinweise'}
|
||
</>
|
||
) : null}
|
||
</span>
|
||
</span>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</Fragment>
|
||
)
|
||
})}
|
||
|
||
{enableSectionDragReorder ? (
|
||
<div
|
||
className={
|
||
'tu-section-dropband tu-section-dropband--end' +
|
||
(dropSectionBand &&
|
||
dropSectionBand.slot === sectionToSlot &&
|
||
dropSectionBand.beforeIdx === list.length
|
||
? ' tu-section-dropband--active'
|
||
: '')
|
||
}
|
||
title="Abschnitt am Ende einfügen"
|
||
onDragOver={(e) => {
|
||
if (!dtHasType(e, DND_TU_SECTION)) return
|
||
onSectionBandDragOver(e, list.length)
|
||
}}
|
||
onDragLeave={(e) => {
|
||
if (e.currentTarget.contains(e.relatedTarget)) return
|
||
clearSectionDnD()
|
||
}}
|
||
onDrop={(e) => onSectionBandDrop(e, list.length)}
|
||
/>
|
||
) : null}
|
||
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||
onClick={addSection}
|
||
>
|
||
+ Abschnitt hinzufügen
|
||
</button>
|
||
|
||
{insertChooser ? (
|
||
<div
|
||
className="tu-textedit-backdrop"
|
||
role="presentation"
|
||
onMouseDown={(e) => {
|
||
if (e.target === e.currentTarget) closeInsertChooser()
|
||
}}
|
||
>
|
||
<div
|
||
className="tu-textedit-panel"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="tu-insert-chooser-title"
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
>
|
||
<h4 id="tu-insert-chooser-title" className="tu-textedit-title">
|
||
An dieser Stelle einfügen
|
||
</h4>
|
||
<p style={{ margin: '0 0 0.75rem', fontSize: '0.86rem', color: 'var(--text2)', lineHeight: 1.45 }}>
|
||
Die neue Zeile erscheint genau hier; Reihenfolge kannst du wie gewohnt per Ziehen oder Pfeilen
|
||
ändern.
|
||
</p>
|
||
<div className="tu-insert-chooser-actions">
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary tu-insert-chooser-actions__full"
|
||
onClick={() => {
|
||
const { sIdx, beforeIx } = insertChooser
|
||
closeInsertChooser()
|
||
onRequestExercisePick?.({
|
||
sectionIndex: sIdx,
|
||
insertBeforeIndex: beforeIx,
|
||
})
|
||
}}
|
||
>
|
||
Übung auswählen …
|
||
</button>
|
||
{onRequestTrainingModulePick ? (
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary tu-insert-chooser-actions__full"
|
||
onClick={() => {
|
||
const ctx = { ...insertChooser }
|
||
closeInsertChooser()
|
||
onRequestTrainingModulePick({
|
||
sectionIndex: ctx.sIdx,
|
||
insertBeforeIndex: ctx.beforeIx,
|
||
})
|
||
}}
|
||
>
|
||
Trainingsmodul …
|
||
</button>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary tu-insert-chooser-actions__full"
|
||
onClick={() => {
|
||
const { sIdx, beforeIx } = insertChooser
|
||
insertItemAt(sIdx, beforeIx, noteRow())
|
||
closeInsertChooser()
|
||
}}
|
||
>
|
||
Zwischen-Anmerkung
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary tu-insert-chooser-actions__full"
|
||
onClick={() => {
|
||
const { sIdx, beforeIx } = insertChooser
|
||
const r = noteRow()
|
||
r.note_body = SECTION_INSERT_SEPARATOR_BODY
|
||
insertItemAt(sIdx, beforeIx, r)
|
||
closeInsertChooser()
|
||
}}
|
||
>
|
||
Trennlinie
|
||
</button>
|
||
<button type="button" className="btn btn-secondary tu-insert-chooser-actions__full" onClick={closeInsertChooser}>
|
||
Abbrechen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{textEdit ? (
|
||
<div
|
||
className="tu-textedit-backdrop"
|
||
role="presentation"
|
||
onMouseDown={(e) => {
|
||
if (e.target === e.currentTarget) setTextEdit(null)
|
||
}}
|
||
>
|
||
<div
|
||
className="tu-textedit-panel"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="tu-textedit-title"
|
||
onMouseDown={(e) => e.stopPropagation()}
|
||
>
|
||
<h4 id="tu-textedit-title" className="tu-textedit-title">
|
||
{textEdit.kind === 'zwischen-note'
|
||
? 'Zwischen-Anmerkung'
|
||
: 'Anmerkung zur Übung'}
|
||
</h4>
|
||
<textarea
|
||
className="form-input tu-textedit-textarea"
|
||
rows={5}
|
||
value={textEdit.draft}
|
||
onChange={(e) =>
|
||
setTextEdit((prev) => (prev ? { ...prev, draft: e.target.value } : prev))
|
||
}
|
||
placeholder={
|
||
textEdit.kind === 'zwischen-note'
|
||
? 'Hinweise zwischen Übungen …'
|
||
: 'Kurze Anmerkung zur Übung'
|
||
}
|
||
/>
|
||
<div className="tu-textedit-actions">
|
||
<button type="button" className="btn btn-primary" onClick={applyTextEdit}>
|
||
Übernehmen
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
onClick={() => setTextEdit(null)}
|
||
>
|
||
Abbrechen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|