feat: enhance training framework UI and responsiveness
Some checks failed
Deploy Development / deploy (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 39s

- Updated app.css to improve layout and responsiveness for mobile and desktop views, including new styles for session slots and chips.
- Modified TrainingUnitSectionsEditor to support a wider exercise grid for better usability.
- Enhanced TrainingFrameworkProgramEditPage with improved slot management and mobile navigation features.
- Introduced dynamic slot chip labels for better user interaction and clarity.
This commit is contained in:
Lars 2026-05-05 14:22:37 +02:00
parent c4fbabd8f6
commit 69adf1fde0
3 changed files with 299 additions and 123 deletions

View File

@ -3012,7 +3012,7 @@ a.analysis-split__nav-item {
line-height: 1.2;
}
/* Horizontaler Überblick: äußerer ScrollContainer (Zuverlässigkeit in Flex/GridEltern) */
/* Horizontaler Überblick: äußerer ScrollContainer (Desktop: breite SessionKarten) */
.framework-slots-board-outer {
container-type: inline-size;
width: 100%;
@ -3029,8 +3029,22 @@ a.analysis-split__nav-item {
scrollbar-gutter: stable;
}
.framework-slots-board-outer--mobile-single {
overflow-x: visible;
scrollbar-gutter: auto;
margin-left: 0;
margin-right: 0;
padding-left: 0;
padding-right: 0;
padding-bottom: 4px;
}
.framework-slots-board-outer--desktop {
scrollbar-gutter: stable;
}
@media (max-width: 1023px) {
.framework-slots-board-outer {
.framework-slots-board-outer:not(.framework-slots-board-outer--mobile-single) {
scrollbar-gutter: auto;
}
}
@ -3047,13 +3061,12 @@ a.analysis-split__nav-item {
scroll-snap-type: x proximity;
}
/* Kartenbreite an den Scroll-Container koppeln (100vw wäre oft breiter als .app-main → horizontales Wischen der ganzen Seite) */
.framework-slots-board .framework-slot-card {
flex: 0 0 min(300px, calc(100vw - 72px));
width: min(300px, calc(100vw - 72px));
min-width: min(300px, calc(100vw - 72px));
height: min(520px, 72vh);
max-height: min(520px, 72vh);
.framework-slots-board--desktop-wide .framework-slot-card {
flex: 0 0 min(760px, max(560px, calc(100cqw - 48px)));
width: min(760px, max(560px, calc(100cqw - 48px)));
min-width: min(760px, max(560px, calc(100cqw - 48px)));
height: auto;
max-height: none;
display: flex;
flex-direction: column;
margin-bottom: 0;
@ -3063,10 +3076,87 @@ a.analysis-split__nav-item {
scroll-snap-align: start;
box-sizing: border-box;
}
.framework-slots-board .framework-slot-card {
flex: 0 0 min(300px, calc(100cqw - 24px));
width: min(300px, calc(100cqw - 24px));
min-width: min(300px, calc(100cqw - 24px));
.framework-slots-board--desktop-wide .framework-slot-card__plan-editor {
flex: 1;
min-height: 240px;
max-height: min(78vh, 1200px);
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
/* Ein Slot = nutzbare Bildschirm­breite; Chips oben/unten wechseln die Session */
.framework-slot-mobile-panel {
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
}
.framework-slot-mobile-panel .framework-slot-card--mobile-single {
flex: none;
width: 100%;
max-width: 100%;
min-width: 0;
height: auto;
max-height: none;
overflow: visible;
scroll-snap-align: unset;
}
.framework-slot-mobile-panel .framework-slot-card__plan-editor {
max-height: none;
overflow-x: hidden;
}
.framework-slot-chips-bar {
display: flex;
flex-wrap: nowrap;
gap: 8px;
overflow-x: auto;
overflow-y: hidden;
padding: 8px 0 10px;
margin-bottom: 2px;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}
.framework-slot-chips-bar--bottom {
margin-bottom: 0;
margin-top: 12px;
padding-top: 6px;
border-top: 1px solid var(--border);
}
.framework-slot-chip {
flex: 0 0 auto;
appearance: none;
margin: 0;
cursor: pointer;
padding: 8px 16px;
border-radius: 999px;
border: 1px solid var(--border2);
background: var(--surface2);
color: var(--text2);
font-size: 0.86rem;
font-weight: 600;
line-height: 1.25;
max-width: 220px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.framework-slot-chip:hover {
border-color: var(--accent);
color: var(--accent-dark);
}
.framework-slot-chip--active {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-text, #fff);
}
.framework-slot-card__head {
@ -3295,11 +3385,6 @@ a.analysis-split__nav-item {
}
@media (min-width: 900px) {
.framework-slots-board .framework-slot-card {
flex-basis: 300px;
width: 300px;
min-width: 300px;
}
.framework-slot-card__slot-actions {
flex-direction: row;
align-items: center;

View File

@ -17,6 +17,7 @@ export default function TrainingUnitSectionsEditor({
showExecutionExtras = false,
heading = 'Abschnitte & Übungen',
hideHeading = false,
wideExerciseGrid = false,
}) {
const ensure = (prev) =>
prev && prev.length ? prev : [defaultSection()]
@ -233,7 +234,9 @@ export default function TrainingUnitSectionsEditor({
key={`ex-${sIdx}-${iIdx}`}
style={{
display: 'grid',
gridTemplateColumns: '28px minmax(0, 1fr) minmax(0, 64px) 36px',
gridTemplateColumns: wideExerciseGrid
? '32px minmax(0, 1fr) minmax(96px, 220px) 44px'
: '28px minmax(0, 1fr) minmax(0, 64px) 36px',
gap: '6px',
alignItems: 'start',
marginTop: '0.65rem',

View File

@ -25,6 +25,11 @@ function reorderArray(arr, from, to) {
return next
}
function slotChipLabel(slot, idx) {
const t = (slot?.title || '').trim()
return t || `Session ${idx + 1}`
}
function emptyGoal() {
return { title: '', notes: '' }
}
@ -187,6 +192,8 @@ export default function TrainingFrameworkProgramEditPage() {
? window.matchMedia(`(min-width: ${FRAMEWORK_DESKTOP_MIN_PX}px)`).matches
: false
)
/** Schmale Ansicht: welcher Session-Slot gerade die volle Breite nutzt (Chip-Navigation) */
const [mobileSlotIdx, setMobileSlotIdx] = useState(0)
useEffect(() => {
const mq = window.matchMedia(`(min-width: ${FRAMEWORK_DESKTOP_MIN_PX}px)`)
@ -233,6 +240,10 @@ export default function TrainingFrameworkProgramEditPage() {
loadMeta()
}, [loadMeta])
useEffect(() => {
setMobileSlotIdx(0)
}, [idParam, isNew])
useEffect(() => {
if (isNew) {
setForm(defaultForm())
@ -307,13 +318,32 @@ export default function TrainingFrameworkProgramEditPage() {
if (j < 0 || j >= prev.slots.length) return prev
const sl = [...prev.slots]
;[sl[idx], sl[j]] = [sl[j], sl[idx]]
setMobileSlotIdx((mi) => {
if (mi === idx) return j
if (mi === j) return idx
return mi
})
return { ...prev, slots: sl }
})
}
const addSlot = () => setForm((prev) => ({ ...prev, slots: [...prev.slots, emptySlot()] }))
const removeSlot = (idx) =>
const addSlot = () => {
setForm((prev) => {
const slots = [...prev.slots, emptySlot()]
setMobileSlotIdx(slots.length - 1)
return { ...prev, slots }
})
}
const removeSlot = (idx) => {
const n = form.slots.length
setMobileSlotIdx((mi) => {
if (idx < mi) return Math.max(0, mi - 1)
if (idx === mi) return Math.min(mi, Math.max(0, n - 2))
return mi
})
setForm((prev) => ({ ...prev, slots: prev.slots.filter((_, i) => i !== idx) }))
}
const slotField = (sIdx, key, val) => {
setForm((prev) => ({
@ -440,6 +470,129 @@ export default function TrainingFrameworkProgramEditPage() {
})
}
const slotChipButtons = (opts) =>
form.slots.map((slot, si) => {
const isActive = si === mobileSlotIdx
const sel = opts?.tabSemantics
const baseClass = `framework-slot-chip${isActive ? ' framework-slot-chip--active' : ''}`
const attrs = sel
? { role: 'tab', id: `fw-slot-chip-${si}`, 'aria-selected': isActive }
: { 'aria-pressed': isActive }
return (
<button key={si} type="button" {...attrs} className={baseClass} onClick={() => setMobileSlotIdx(si)} title={slotChipLabel(slot, si)}>
{slotChipLabel(slot, si)}
</button>
)
})
const renderFrameworkSlotCard = (si) => {
const slot = form.slots[si]
if (!slot) return null
return (
<div
key={si}
className={`card framework-slot-card${!desktopLayout ? ' framework-slot-card--mobile-single' : ''}`}
onDragOver={desktopLayout ? onSlotColumnDragOver : undefined}
onDrop={desktopLayout ? (e) => onSlotColumnDrop(e, si) : undefined}
>
<div className="framework-slot-card__head">
{desktopLayout ? (
<span
role="button"
tabIndex={0}
className="framework-slot-card__drag-handle"
draggable
onDragStart={(e) => onSlotDragStart(e, si)}
aria-label="Slot ziehen: Reihenfolge ändern"
title="Slot ziehen (Reihenfolge)"
>
</span>
) : null}
<div className="framework-slot-card__head-main">
<span className="framework-slot-card__slot-label">Session {si + 1}</span>
<input
className="form-input framework-slot-card__title-input"
value={slot.title}
onChange={(e) => slotField(si, 'title', e.target.value)}
placeholder={`z.B. Woche ${si + 1}`}
/>
</div>
<div className="framework-slot-card__slot-actions">
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => moveSlot(si, -1)}
aria-label="Slot nach links / oben"
>
</button>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => moveSlot(si, 1)}
aria-label="Slot nach rechts / unten"
>
</button>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => removeSlot(si)}
>
×
</button>
</div>
</div>
<details className="framework-slot-details">
<summary className="framework-slot-details__summary">Notizen (Session)</summary>
<div className="form-row">
<label className="form-label">Notizen</label>
<textarea
className="form-input"
rows={2}
value={slot.notes}
onChange={(e) => slotField(si, 'notes', e.target.value)}
/>
</div>
</details>
<div className="framework-slot-card__plan-editor" style={{ marginTop: '0.65rem', minHeight: '120px' }}>
<TrainingUnitSectionsEditor
heading={`Ablauf · Session ${si + 1}`}
sections={slot.sections}
showExecutionExtras={false}
wideExerciseGrid
onSectionsChange={(updater) => {
setForm((prev) => ({
...prev,
slots: prev.slots.map((sl, ii) =>
ii !== si
? sl
: {
...sl,
sections: updater(
sl.sections && sl.sections.length ? sl.sections : [defaultSection('Ablauf')]
),
}
),
}))
}}
onRequestExercisePick={({ sectionIndex, itemIndex }) =>
setSectionPickerCtx({
slotIdx: si,
sectionIndex,
itemIndex,
})
}
onPeekExercise={(id) => setPeekId(id)}
/>
</div>
</div>
)
}
if (loading) {
return (
<div className="app-page" style={{ padding: '2rem 0', textAlign: 'center' }}>
@ -840,114 +993,49 @@ export default function TrainingFrameworkProgramEditPage() {
className="framework-slots-hint"
style={{ fontSize: '0.8rem', color: 'var(--text3)', marginBottom: '10px', lineHeight: 1.45 }}
>
Reihenfolge der Spalten per Griff () ziehen; Inhalt eines Slots wie in der Planung bearbeiten.
{desktopLayout ? (
<>
Reihenfolge der Spalten per Griff () ziehen oder mit / verschieben; Inhalt eines Slots wie
in der Planung bearbeiten (Abschnitte, Übungen, Anmerkungen).
</>
) : (
<>
Sessions oben oder unten per Chips wählen ein Slot nutzt die volle Breite. Reihenfolge mit /;
bearbeiten wie in der Planung.
</>
)}
</p>
)}
<div className="framework-slots-board-outer">
<div className="framework-slots-board">
{form.slots.map((slot, si) => (
{form.slots.length > 0 ? (
!desktopLayout ? (
<div className="framework-slots-board-outer framework-slots-board-outer--mobile-single">
<div
key={si}
className="card framework-slot-card"
onDragOver={onSlotColumnDragOver}
onDrop={(e) => onSlotColumnDrop(e, si)}
className="framework-slot-chips-bar framework-slot-chips-bar--top"
role="tablist"
aria-label="Sessions"
>
<div className="framework-slot-card__head">
<span
role="button"
tabIndex={0}
className="framework-slot-card__drag-handle"
draggable
onDragStart={(e) => onSlotDragStart(e, si)}
aria-label="Slot ziehen: Reihenfolge ändern"
title="Slot ziehen (Reihenfolge)"
>
</span>
<div className="framework-slot-card__head-main">
<span className="framework-slot-card__slot-label">Session {si + 1}</span>
<input
className="form-input framework-slot-card__title-input"
value={slot.title}
onChange={(e) => slotField(si, 'title', e.target.value)}
placeholder={`z.B. Woche ${si + 1}`}
/>
</div>
<div className="framework-slot-card__slot-actions">
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => moveSlot(si, -1)}
aria-label="Slot nach links / oben"
>
</button>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => moveSlot(si, 1)}
aria-label="Slot nach rechts / unten"
>
</button>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => removeSlot(si)}
>
×
</button>
</div>
</div>
<details className="framework-slot-details">
<summary className="framework-slot-details__summary">Notizen (Session)</summary>
<div className="form-row">
<label className="form-label">Notizen</label>
<textarea
className="form-input"
rows={2}
value={slot.notes}
onChange={(e) => slotField(si, 'notes', e.target.value)}
/>
</div>
</details>
<div className="framework-slot-card__plan-editor" style={{ marginTop: '0.65rem', minHeight: '120px' }}>
<TrainingUnitSectionsEditor
heading={`Ablauf · Session ${si + 1}`}
sections={slot.sections}
showExecutionExtras={false}
onSectionsChange={(updater) => {
setForm((prev) => ({
...prev,
slots: prev.slots.map((sl, ii) =>
ii !== si
? sl
: {
...sl,
sections: updater(
sl.sections && sl.sections.length ? sl.sections : [defaultSection('Ablauf')]
),
}
),
}))
}}
onRequestExercisePick={({ sectionIndex, itemIndex }) =>
setSectionPickerCtx({
slotIdx: si,
sectionIndex,
itemIndex,
})
}
onPeekExercise={(id) => setPeekId(id)}
/>
</div>
{slotChipButtons({ tabSemantics: true })}
</div>
))}
</div>
</div>
<div className="framework-slot-mobile-panel" tabIndex={-1}>
{renderFrameworkSlotCard(mobileSlotIdx)}
</div>
<div
className="framework-slot-chips-bar framework-slot-chips-bar--bottom"
role="group"
aria-label="Sessions (Anwahl unten)"
>
{slotChipButtons({ tabSemantics: false })}
</div>
</div>
) : (
<div className="framework-slots-board-outer framework-slots-board-outer--desktop">
<div className="framework-slots-board framework-slots-board--desktop-wide">
{form.slots.map((_, si) => renderFrameworkSlotCard(si))}
</div>
</div>
)
) : null}
</div>
</div>
</div>