Enhance full-page editor layout and integrate form editor actions
Some checks failed
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Failing after 6m4s

- Updated CSS styles for the full-page editor, introducing a sticky header and mobile dock for improved navigation.
- Refactored App component to include FormEditorActionsProvider and FormEditorBottomSlot for better form handling.
- Simplified TrainingUnitFormShell by removing the FormActionBar, streamlining the form structure and enhancing usability.
This commit is contained in:
Lars 2026-05-19 11:18:54 +02:00
parent 734d943d73
commit 6a9351874f
6 changed files with 253 additions and 137 deletions

View File

@ -7,7 +7,7 @@ import {
useLocation,
Outlet,
} from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext'
import { FormEditorActionsProvider, FormEditorBottomSlot } from './context/FormEditorActionsContext'
import { ToastProvider } from './context/ToastContext'
import { OrgInboxProvider, useOrgInbox } from './context/OrgInboxContext'
import DesktopSidebar from './components/DesktopSidebar'
@ -145,22 +145,26 @@ function ProtectedLayout() {
return (
<OrgInboxProvider user={user}>
<DesktopSidebar showAdminNav={showAdminNav} user={user} onLogout={handleLogout} />
<div className="app-shell">
<div className="app-shell__column">
<div className="app-header app-header--mobile app-header--mobile-stack">
<div className="app-header-mobile__top">
<div className="app-logo">🥋 Shinkan</div>
<FormEditorActionsProvider>
<DesktopSidebar showAdminNav={showAdminNav} user={user} onLogout={handleLogout} />
<div className="app-shell">
<div className="app-shell__column">
<div className="app-header app-header--mobile app-header--mobile-stack">
<div className="app-header-mobile__top">
<div className="app-logo">🥋 Shinkan</div>
</div>
<ActiveClubSwitcher variant="mobile" />
</div>
<ActiveClubSwitcher variant="mobile" />
<div className="app-main">
<InactiveMembershipBanner />
<Outlet />
</div>
<FormEditorBottomSlot>
<Nav showAdminNav={showAdminNav} />
</FormEditorBottomSlot>
</div>
<div className="app-main">
<InactiveMembershipBanner />
<Outlet />
</div>
<Nav showAdminNav={showAdminNav} />
</div>
</div>
</FormEditorActionsProvider>
</OrgInboxProvider>
)
}

View File

@ -1450,61 +1450,87 @@ html.modal-scroll-locked .app-main {
padding-right: var(--page-pad, 16px);
}
/* Einheiten-Editor (Vollseite): Scroll im Formular, Action Bar am unteren Formularrand */
.app-main:has(.planning-unit-edit-host) {
overflow: hidden;
display: flex;
flex-direction: column;
/* Vollseiten-Editor: Kopfzeile (Desktop) + Mobile-Dock statt Bottom-Nav */
.page-form-editor__header {
position: sticky;
top: 0;
z-index: 12;
background: var(--bg);
padding-bottom: 0.75rem;
margin-bottom: 0.75rem;
border-bottom: 1px solid var(--border);
}
.planning-layout:has(.planning-unit-edit-host) {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
.page-form-editor__intro {
min-width: 0;
}
.planning-layout__main:has(.planning-unit-edit-host) {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
.page-form-editor__back {
display: inline-block;
margin-bottom: 0.35rem;
color: var(--accent-dark);
font-weight: 600;
font-size: 0.9rem;
text-decoration: none;
}
.planning-unit-edit-host {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
.page-form-editor__title {
margin: 0;
font-size: clamp(1.15rem, 4vw, 1.45rem);
line-height: 1.25;
}
.planning-unit-edit-host__body {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
.page-form-editor__header-actions {
display: none;
}
.page-form-shell--fill {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.page-form-shell--fill .page-form-shell__scroll {
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
overscroll-behavior-x: none;
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
padding-bottom: 4px;
}
.page-form-shell--fill .form-action-bar {
.page-form-editor__header-actions .form-action-bar {
position: static;
margin-top: auto;
flex-shrink: 0;
border: none;
box-shadow: none;
padding: 0;
margin: 0;
background: transparent;
}
.form-action-bar--page {
flex-shrink: 0;
.form-editor-mobile-dock {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
width: auto;
max-width: none;
background: var(--surface);
border-top: 1px solid var(--border);
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
padding: var(--nav-pad-top, 8px) max(10px, env(safe-area-inset-right, 0px))
env(safe-area-inset-bottom, 0px) max(10px, env(safe-area-inset-left, 0px));
box-sizing: border-box;
}
.form-editor-mobile-dock .form-action-bar {
position: static;
border: none;
box-shadow: none;
margin: 0;
}
@media (min-width: 1024px) {
.page-form-editor__header {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 12px 16px;
}
.page-form-editor__header-actions {
display: block;
flex: 1 1 320px;
min-width: min(100%, 420px);
}
.form-editor-mobile-dock {
display: none !important;
}
}
@media (max-width: 1023px) {
.app-shell__column:has(.form-editor-mobile-dock) .app-main {
padding-bottom: calc(52px + var(--nav-pad-top, 8px) + env(safe-area-inset-bottom, 0px) + 12px);
}
}
/* Einstellungen: gleiche Split-Struktur wie Analyse/Admin */

View File

@ -0,0 +1,48 @@
import React, { useMemo } from 'react'
import { Link } from 'react-router-dom'
import FormActionBar from './FormActionBar'
import { useFormEditorActions } from '../context/FormEditorActionsContext'
/**
* Vollseiten-Editor: sticky Kopfzeile (Desktop) + Mobile-Dock via FormEditorActionsProvider.
*/
export default function PageFormEditorChrome({
title,
backTo,
backLabel = 'Zurück',
actionConfig,
children,
testId,
}) {
useFormEditorActions(actionConfig)
const headerBar = useMemo(() => {
if (!actionConfig) return null
return (
<FormActionBar
{...actionConfig}
placement="top"
variant="page"
/>
)
}, [actionConfig])
return (
<div className="page-form-editor" data-testid={testId}>
<header className="page-form-editor__header">
<div className="page-form-editor__intro">
{backTo ? (
<Link to={backTo} className="page-form-editor__back">
{backLabel}
</Link>
) : null}
<h1 className="page-form-editor__title">{title}</h1>
</div>
{headerBar ? (
<div className="page-form-editor__header-actions">{headerBar}</div>
) : null}
</header>
<div className="page-form-editor__body">{children}</div>
</div>
)
}

View File

@ -1,6 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import FormActionBar from '../FormActionBar'
import TrainingPlanExerciseVisibilityPanel from '../TrainingPlanExerciseVisibilityPanel'
import TrainingUnitSectionsEditor from '../TrainingUnitSectionsEditor'
import { activeClubMemberships } from '../../utils/activeClub'
@ -16,7 +15,6 @@ export default function TrainingUnitFormShell({
setFormData,
onSaveOnly,
onSaveAndClose,
onCancel,
draftPlanTemplateId,
onDraftTemplateSelect,
planTemplates,
@ -33,7 +31,6 @@ export default function TrainingUnitFormShell({
onRequestTrainingModulePick,
onRequestExercisePick,
onPeekExercise,
saving = false,
formId = 'planning-unit-form',
}) {
const [newTplVisibility, setNewTplVisibility] = useState('private')
@ -52,14 +49,9 @@ export default function TrainingUnitFormShell({
}, [planningClubId, memberClubs])
return (
<div className="planning-unit-edit-host__body" data-testid="planning-unit-form">
<h1 className="page-title" style={{ marginBottom: '1rem', flexShrink: 0 }}>
{editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'}
</h1>
<form
id={formId}
className="card page-form-shell page-form-shell--fill"
className="card page-form-shell"
style={{ padding: 'clamp(14px, 3vw, 1.75rem)' }}
onSubmit={(e) => (onSaveAndClose ? onSaveAndClose(e) : onSaveOnly?.(e))}
>
@ -488,20 +480,6 @@ export default function TrainingUnitFormShell({
/>
</div>
</div>
<FormActionBar
placement="bottom"
variant="page"
formId={formId}
saving={saving}
isNew={!editingUnit}
onSave={onSaveOnly ? () => onSaveOnly() : undefined}
onSaveAndClose={onSaveAndClose ? () => onSaveAndClose() : undefined}
onCancel={onCancel}
showSave={Boolean(onSaveOnly)}
showSaveAndClose
/>
</form>
</div>
)
}

View File

@ -0,0 +1,38 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'
import FormActionBar from '../components/FormActionBar'
const FormEditorActionsContext = createContext(null)
export function FormEditorActionsProvider({ children }) {
const [config, setConfig] = useState(null)
const value = useMemo(() => ({ config, setConfig }), [config])
return <FormEditorActionsContext.Provider value={value}>{children}</FormEditorActionsContext.Provider>
}
/** Mobile: FormActionBar statt Bottom-Nav; Desktop: Fallback (Nav ist ohnehin ausgeblendet). */
export function FormEditorBottomSlot({ children }) {
const ctx = useContext(FormEditorActionsContext)
const config = ctx?.config
if (config) {
return (
<div className="form-editor-mobile-dock" data-testid="form-editor-mobile-dock">
<FormActionBar {...config} placement="bottom" variant="page" />
</div>
)
}
return children
}
/** @param {Record<string, unknown> | null} config FormActionBar-Props */
export function useFormEditorActions(config) {
const setConfig = useContext(FormEditorActionsContext)?.setConfig
useEffect(() => {
if (!setConfig) return undefined
setConfig(config ?? null)
return () => setConfig(null)
}, [setConfig, config])
}

View File

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { useToast } from '../context/ToastContext'
@ -28,6 +28,7 @@ import {
trainingUnitToFormFields,
validateTrainingUnitFormForSave,
} from '../utils/trainingUnitEditorCore'
import PageFormEditorChrome from '../components/PageFormEditorChrome'
import { buildPlanUnitEditPath, planningHubPathFromReturnState } from '../utils/planningUnitRoutes'
export default function TrainingUnitEditPage() {
@ -396,50 +397,73 @@ export default function TrainingUnitEditPage() {
}
}
const reloadUnitAfterSave = async (savedId) => {
const fullUnit = await api.getTrainingUnit(savedId)
setEditingUnit(fullUnit)
let sections = normalizeUnitToForm(fullUnit)
sections = await enrichSectionsWithVariants(sections)
setFormData(trainingUnitToFormFields(fullUnit, sections))
if (!isNew && savedId !== unitId) {
navigate(buildPlanUnitEditPath(savedId), { replace: true, state: location.state })
}
}
const reloadUnitAfterSave = useCallback(
async (savedId) => {
const fullUnit = await api.getTrainingUnit(savedId)
setEditingUnit(fullUnit)
let sections = normalizeUnitToForm(fullUnit)
sections = await enrichSectionsWithVariants(sections)
setFormData(trainingUnitToFormFields(fullUnit, sections))
if (!isNew && savedId !== unitId) {
navigate(buildPlanUnitEditPath(savedId), { replace: true, state: location.state })
}
},
[isNew, unitId, navigate, location.state]
)
const handleSubmit = async (e, { closeAfter = true } = {}) => {
e?.preventDefault?.()
const v = validateTrainingUnitFormForSave(formData)
if (!v.ok) {
toast.error(v.message)
return false
}
setSaving(true)
try {
const payload = buildTrainingUnitSavePayload(formData, {
editingUnit,
draftPlanTemplateId,
})
let savedUnit
if (editingUnit) {
savedUnit = await api.updateTrainingUnit(editingUnit.id, payload)
} else {
savedUnit = await api.createTrainingUnit(payload)
const handleSubmit = useCallback(
async (e, { closeAfter = true } = {}) => {
e?.preventDefault?.()
const v = validateTrainingUnitFormForSave(formData)
if (!v.ok) {
toast.error(v.message)
return false
}
toast.success('Gespeichert.')
if (closeAfter) {
goBack()
} else if (savedUnit?.id) {
await reloadUnitAfterSave(savedUnit.id)
setSaving(true)
try {
const payload = buildTrainingUnitSavePayload(formData, {
editingUnit,
draftPlanTemplateId,
})
let savedUnit
if (editingUnit) {
savedUnit = await api.updateTrainingUnit(editingUnit.id, payload)
} else {
savedUnit = await api.createTrainingUnit(payload)
}
toast.success('Gespeichert.')
if (closeAfter) {
goBack()
} else if (savedUnit?.id) {
await reloadUnitAfterSave(savedUnit.id)
}
return true
} catch (err) {
toast.error('Fehler beim Speichern: ' + err.message)
return false
} finally {
setSaving(false)
}
return true
} catch (err) {
toast.error('Fehler beim Speichern: ' + err.message)
return false
} finally {
setSaving(false)
}
}
},
[formData, editingUnit, draftPlanTemplateId, toast, goBack, reloadUnitAfterSave]
)
const actionConfig = useMemo(
() => ({
formId: 'planning-unit-form',
saving,
isNew: !editingUnit,
onSave: (e) => handleSubmit(e, { closeAfter: false }),
onSaveAndClose: (e) => handleSubmit(e, { closeAfter: true }),
onCancel: goBack,
showSave: true,
showSaveAndClose: true,
}),
[saving, editingUnit, handleSubmit, goBack]
)
const hubBackPath = planningHubPathFromReturnState(location.state?.planningReturn)
const pageTitle = editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'
const handleSaveAsTemplate = async (opts = {}) => {
const name = window.prompt('Name für die neue Trainingsvorlage (nur Abschnitts-Gliederung):')
@ -568,13 +592,13 @@ export default function TrainingUnitEditPage() {
}
return (
<div className="planning-unit-edit-host">
<p style={{ marginBottom: '0.75rem', flexShrink: 0 }}>
<Link to={planningHubPathFromReturnState(location.state?.planningReturn)} style={{ color: 'var(--accent-dark)' }}>
Zurück zur Trainingsplanung
</Link>
</p>
<PageFormEditorChrome
testId="planning-unit-form"
title={pageTitle}
backTo={hubBackPath}
backLabel="Trainingsplanung"
actionConfig={actionConfig}
>
<TrainingUnitFormShell
editingUnit={editingUnit}
formData={formData}
@ -582,7 +606,6 @@ export default function TrainingUnitEditPage() {
setFormData={setFormData}
onSaveOnly={(e) => handleSubmit(e, { closeAfter: false })}
onSaveAndClose={(e) => handleSubmit(e, { closeAfter: true })}
onCancel={goBack}
draftPlanTemplateId={draftPlanTemplateId}
onDraftTemplateSelect={applyTemplateFromSelect}
planTemplates={planTemplates}
@ -611,7 +634,6 @@ export default function TrainingUnitEditPage() {
onPeekExercise={(id, variantId, peekExtras) =>
setPlanningPeekCtx({ exerciseId: id, variantId: variantId ?? null, peekExtras: peekExtras ?? null })
}
saving={saving}
/>
<TrainingPlanningModuleApplyModal
@ -704,6 +726,6 @@ export default function TrainingUnitEditPage() {
peekExtras={planningPeekCtx?.peekExtras ?? undefined}
onClose={() => setPlanningPeekCtx(null)}
/>
</div>
</PageFormEditorChrome>
)
}