From 44f224e5d162138940fad7f71fee176e8d07522f Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 5 May 2026 12:19:03 +0200 Subject: [PATCH] feat: enhance TrainingFrameworkProgramEditPage with drag-and-drop functionality and layout improvements - Updated app.css to refine responsive design for mobile and desktop views, ensuring better usability. - Implemented drag-and-drop features for exercises and slots in TrainingFrameworkProgramEditPage, enhancing user interaction. - Adjusted tab management and layout visibility based on screen size, improving overall user experience. - Incremented version of TrainingFrameworkProgramEditPage to 1.3.0 to reflect the latest enhancements. --- frontend/src/app.css | 275 ++++++- .../TrainingFrameworkProgramEditPage.jsx | 692 +++++++++++------- frontend/src/version.js | 2 +- 3 files changed, 699 insertions(+), 270 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index 008cda0..e352aeb 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -2713,10 +2713,12 @@ a.analysis-split__nav-item { accent-color: var(--accent); } -/* Rahmenprogramm bearbeiten — Mobile Tabs, Desktop Ziele | Slots nebeneinander (synchron zu FRAMEWORK_DESKTOP_MIN_PX im Editor) */ +/* Rahmenprogramm bearbeiten — Mobile: Stammdaten | Plan; Desktop: untereinander Ziele → Slots (synchron zu FRAMEWORK_DESKTOP_MIN_PX) */ .framework-edit { max-width: 800px; margin: 0 auto; + width: 100%; + min-width: 0; } @media (min-width: 900px) { .framework-edit { @@ -2725,13 +2727,11 @@ a.analysis-split__nav-item { .framework-edit__tabbar { display: none !important; } - .framework-edit__goals-slots { - display: grid; - grid-template-columns: 1fr 1fr; + .framework-edit__plan-stack { + display: flex; + flex-direction: column; gap: 16px; - align-items: start; } - /* breit: alle Bereiche sichtbar */ .framework-edit__panel { display: block !important; } @@ -2769,8 +2769,10 @@ a.analysis-split__nav-item { color: var(--accent-dark); border-color: var(--accent); } -.framework-edit__goals-slots { - display: block; +.framework-edit__plan-stack { + display: flex; + flex-direction: column; + gap: 14px; } @media (max-width: 899px) { .framework-edit .framework-edit__panel:not(.framework-edit__panel--active) { @@ -2778,33 +2780,266 @@ a.analysis-split__nav-item { } } -/* Rahmen-Editor: Slots (= Session‑Spalten) horizontal, scrollbar */ +.framework-plan-goals { + border-left: 3px solid var(--accent); +} + +.framework-ctrl.framework-ctrl--xs { + padding: 2px 8px; + font-size: 11px; + min-height: 26px; + line-height: 1.2; +} + +/* Horizontaler Überblick: äußerer Scroll‑Container (Zuverlässigkeit in Flex/Grid‑Eltern) */ +.framework-slots-board-outer { + width: 100%; + max-width: 100%; + min-width: 0; + overflow-x: auto; + overflow-y: visible; + padding-bottom: 10px; + margin-left: -4px; + margin-right: -4px; + padding-left: 4px; + padding-right: 4px; + -webkit-overflow-scrolling: touch; + scrollbar-gutter: stable; +} + .framework-slots-board { display: flex; flex-direction: row; flex-wrap: nowrap; gap: 12px; align-items: stretch; - overflow-x: auto; - overflow-y: hidden; - padding: 4px 2px 12px; - margin: 0 -4px; - -webkit-overflow-scrolling: touch; + width: max-content; + min-width: 100%; + padding: 4px 0 2px; scroll-snap-type: x proximity; } + .framework-slots-board .framework-slot-card { - flex: 0 0 min(320px, calc(100vw - 48px)); - min-width: min(320px, calc(100vw - 48px)); - max-height: min(70vh, 720px); - overflow-x: hidden; - overflow-y: auto; + flex: 0 0 min(300px, calc(100vw - 56px)); + width: min(300px, calc(100vw - 56px)); + min-width: min(300px, calc(100vw - 56px)); + height: min(520px, 72vh); + max-height: min(520px, 72vh); + display: flex; + flex-direction: column; + margin-bottom: 0; + background: var(--surface); + border-style: dashed; + overflow: hidden; scroll-snap-align: start; box-sizing: border-box; } + +.framework-slot-card__head { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 6px; + flex-shrink: 0; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} + +.framework-slot-card__drag-handle { + flex: 0 0 auto; + cursor: grab; + user-select: none; + font-size: 14px; + line-height: 1; + padding: 6px 4px; + color: var(--text3); + border-radius: 6px; +} + +.framework-slot-card__drag-handle:active { + cursor: grabbing; +} + +.framework-slot-card__head-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.framework-slot-card__slot-label { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text3); +} + +.framework-slot-card__title-input { + padding: 6px 10px; + font-size: 14px; + font-weight: 600; +} + +.framework-slot-card__slot-actions { + display: flex; + flex-direction: column; + gap: 4px; + flex-shrink: 0; +} + +.framework-slot-details { + flex-shrink: 0; + margin-top: 8px; + font-size: 0.88rem; + border-radius: 8px; + background: var(--surface2); + border: 1px solid var(--border); +} + +.framework-slot-details__summary { + cursor: pointer; + padding: 6px 10px; + font-weight: 600; + color: var(--text2); + list-style: none; +} + +.framework-slot-details__summary::-webkit-details-marker { + display: none; +} + +.framework-slot-details .form-row { + margin-bottom: 10px; + padding: 0 10px 8px; +} + +.framework-slot-card__exercises { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + margin-top: 10px; + gap: 6px; + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; +} + +.framework-slot-card__exercises-head { + display: flex; + justify-content: space-between; + align-items: center; + flex-shrink: 0; + gap: 8px; +} + +.framework-slot-card__exercises-title { + font-weight: 700; + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--accent-dark); +} + +.framework-slot-card__empty-hint { + font-size: 0.82rem; + color: var(--text2); + margin: 4px 0 6px; +} + +.framework-ex-row { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 6px; + padding: 8px 8px; + border-radius: 8px; + border: 1px solid var(--border2); + background: var(--surface2); + flex-shrink: 0; +} + +.framework-ex-row__grip { + flex: 0 0 auto; + cursor: grab; + user-select: none; + line-height: 1.4; + padding-top: 2px; + color: var(--text3); + font-size: 14px; +} + +.framework-ex-row__grip:active { + cursor: grabbing; +} + +.framework-ex-row__body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.framework-ex-row__title-line { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px 10px; +} + +.framework-ex-row__title { + font-size: 0.98rem; + line-height: 1.35; +} + +.framework-ex-row__title--muted { + color: var(--text3); + font-weight: 500; +} + +.framework-ex-row__id { + font-size: 11px; + color: var(--text3); +} + +.framework-ex-row__toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.framework-ex-row__variant-select { + flex: 1 1 140px; + min-width: 120px; + max-width: 100%; + padding: 6px 8px; + font-size: 13px; +} + +.framework-slot-card__append-drop { + margin-top: 4px; + padding: 8px 10px; + font-size: 11px; + color: var(--text3); + border: 1px dashed var(--border2); + border-radius: 8px; + text-align: center; + flex-shrink: 0; +} + @media (min-width: 900px) { .framework-slots-board .framework-slot-card { flex-basis: 300px; - min-width: 280px; + width: 300px; + min-width: 300px; + } + .framework-slot-card__slot-actions { + flex-direction: row; + align-items: center; } } diff --git a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx index db922c7..9ae0677 100644 --- a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx +++ b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx @@ -4,9 +4,21 @@ import api from '../utils/api' import ExercisePickerModal from '../components/ExercisePickerModal' import ExercisePeekModal from '../components/ExercisePeekModal' -/** Unter dieser Breite: Registerkarten; darüber: Ziele | Slots nebeneinander (muss zu app.css passen) */ +/** Unter dieser Breite: 2 Tabs (Stammdaten | Plan); darüber: alles untereinander */ const FRAMEWORK_DESKTOP_MIN_PX = 900 +const DND_FW_EX = 'application/x-shinkan-framework-exercise' +const DND_FW_SLOT = 'application/x-shinkan-framework-slot' + +function reorderArray(arr, from, to) { + if (from === to || from < 0 || from >= arr.length) return [...arr] + const next = [...arr] + const [it] = next.splice(from, 1) + const t = Math.max(0, Math.min(to, next.length)) + next.splice(t, 0, it) + return next +} + function emptyGoal() { return { title: '', notes: '' } } @@ -178,7 +190,7 @@ export default function TrainingFrameworkProgramEditPage() { const [units, setUnits] = useState([]) const [pickerSlotIdx, setPickerSlotIdx] = useState(null) const [peekId, setPeekId] = useState(null) - /** Nur schmal: welcher Block sichtbar — Desktop zeigt Stammdaten + zwei Spalten Ziele|Slots */ + /** Nur schmal: Stammdaten | Plan (Ziele übereinander, darunter Slots) — Desktop: alles sichtbar */ const [frameworkTab, setFrameworkTab] = useState('meta') const [desktopLayout, setDesktopLayout] = useState( typeof window !== 'undefined' @@ -447,7 +459,82 @@ export default function TrainingFrameworkProgramEditPage() { } } - const panelActive = (key) => desktopLayout || frameworkTab === key + const insertExerciseBefore = (fromS, fromE, toS, toE) => { + setForm((prev) => { + const slots = prev.slots.map((s) => ({ ...s, exercises: [...(s.exercises || [])] })) + if (fromS < 0 || fromS >= slots.length || toS < 0 || toS >= slots.length) return prev + const fromList = slots[fromS].exercises + if (fromE < 0 || fromE >= fromList.length) return prev + const [moved] = fromList.splice(fromE, 1) + let insertAt = toE + if (fromS === toS && fromE < toE) insertAt -= 1 + insertAt = Math.max(0, Math.min(insertAt, slots[toS].exercises.length)) + slots[toS].exercises.splice(insertAt, 0, moved) + return { ...prev, slots } + }) + } + + const appendExerciseToSlot = (fromS, fromE, toS) => { + setForm((prev) => { + const slots = prev.slots.map((s) => ({ ...s, exercises: [...(s.exercises || [])] })) + if (fromS < 0 || fromS >= slots.length || toS < 0 || toS >= slots.length) return prev + const fromList = slots[fromS].exercises + if (fromE < 0 || fromE >= fromList.length) return prev + const [moved] = fromList.splice(fromE, 1) + slots[toS].exercises.push(moved) + return { ...prev, slots } + }) + } + + const onExerciseDragStart = (e, fromS, fromE) => { + if (!desktopLayout) return + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData(DND_FW_EX, JSON.stringify({ fromS, fromE })) + } + + const onExerciseDragOver = (e) => { + if (!desktopLayout) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + } + + const onSlotDragStart = (e, slotIdx) => { + if (!desktopLayout) return + e.stopPropagation() + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData(DND_FW_SLOT, JSON.stringify({ slotIdx })) + } + + const onSlotColumnDragOver = (e) => { + if (!desktopLayout) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + } + + const onSlotColumnDrop = (e, targetSi) => { + e.preventDefault() + if (!desktopLayout) return + const slotRaw = e.dataTransfer.getData(DND_FW_SLOT) + if (slotRaw) { + const { slotIdx } = JSON.parse(slotRaw) + if (slotIdx !== targetSi) { + setForm((prev) => ({ ...prev, slots: reorderArray([...prev.slots], slotIdx, targetSi) })) + } + return + } + const exRaw = e.dataTransfer.getData(DND_FW_EX) + if (exRaw) { + const { fromS, fromE } = JSON.parse(exRaw) + appendExerciseToSlot(fromS, fromE, targetSi) + } + } + + const panelActive = (key) => { + if (desktopLayout) return true + if (key === 'meta') return frameworkTab === 'meta' + if (key === 'plan') return frameworkTab === 'plan' + return false + } /** Schmale Ansicht: Sichtbarkeit per Inline (falls globales CSS nicht greift / altes Bundle) */ const panelVisibilityStyle = (key) => @@ -510,8 +597,7 @@ export default function TrainingFrameworkProgramEditPage() { > {[ { id: 'meta', label: 'Stammdaten' }, - { id: 'goals', label: 'Ziele' }, - { id: 'slots', label: 'Slots & Übungen' }, + { id: 'plan', label: 'Plan (Ziele & Sessions)' }, ].map((t) => ( - - {(form.goals || []).map((g, gi) => ( -
-
- - -
-
- - - setForm((prev) => ({ - ...prev, - goals: prev.goals.map((x, i) => (i === gi ? { ...x, title: e.target.value } : x)), - })) - } - /> -
-
- -