From 6a9351874f526826dd556c6e8746740efcabbeee Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 19 May 2026 11:18:54 +0200 Subject: [PATCH] Enhance full-page editor layout and integrate form editor actions - 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. --- frontend/src/App.jsx | 32 +++-- frontend/src/app.css | 122 ++++++++++------- .../src/components/PageFormEditorChrome.jsx | 48 +++++++ .../planning/TrainingUnitFormShell.jsx | 24 +--- .../src/context/FormEditorActionsContext.jsx | 38 ++++++ frontend/src/pages/TrainingUnitEditPage.jsx | 126 ++++++++++-------- 6 files changed, 253 insertions(+), 137 deletions(-) create mode 100644 frontend/src/components/PageFormEditorChrome.jsx create mode 100644 frontend/src/context/FormEditorActionsContext.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3c4bb10..9b71dd7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 ( - -
-
-
-
-
đŸ„‹ Shinkan
+ + +
+
+
+
+
đŸ„‹ Shinkan
+
+
- +
+ + +
+ +
-
- - -
-
-
+ ) } diff --git a/frontend/src/app.css b/frontend/src/app.css index 05af7a3..ebbfa05 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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 */ diff --git a/frontend/src/components/PageFormEditorChrome.jsx b/frontend/src/components/PageFormEditorChrome.jsx new file mode 100644 index 0000000..4395664 --- /dev/null +++ b/frontend/src/components/PageFormEditorChrome.jsx @@ -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 ( + + ) + }, [actionConfig]) + + return ( +
+
+
+ {backTo ? ( + + ← {backLabel} + + ) : null} +

{title}

+
+ {headerBar ? ( +
{headerBar}
+ ) : null} +
+
{children}
+
+ ) +} diff --git a/frontend/src/components/planning/TrainingUnitFormShell.jsx b/frontend/src/components/planning/TrainingUnitFormShell.jsx index be75b2e..8406a45 100644 --- a/frontend/src/components/planning/TrainingUnitFormShell.jsx +++ b/frontend/src/components/planning/TrainingUnitFormShell.jsx @@ -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 ( -
-

- {editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'} -

-
(onSaveAndClose ? onSaveAndClose(e) : onSaveOnly?.(e))} > @@ -488,20 +480,6 @@ export default function TrainingUnitFormShell({ />
- - onSaveOnly() : undefined} - onSaveAndClose={onSaveAndClose ? () => onSaveAndClose() : undefined} - onCancel={onCancel} - showSave={Boolean(onSaveOnly)} - showSaveAndClose - /> -
) } diff --git a/frontend/src/context/FormEditorActionsContext.jsx b/frontend/src/context/FormEditorActionsContext.jsx new file mode 100644 index 0000000..ad5296b --- /dev/null +++ b/frontend/src/context/FormEditorActionsContext.jsx @@ -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 {children} +} + +/** 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 ( +
+ +
+ ) + } + + return children +} + +/** @param {Record | 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]) +} diff --git a/frontend/src/pages/TrainingUnitEditPage.jsx b/frontend/src/pages/TrainingUnitEditPage.jsx index 571446f..a44eaf8 100644 --- a/frontend/src/pages/TrainingUnitEditPage.jsx +++ b/frontend/src/pages/TrainingUnitEditPage.jsx @@ -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 ( -
-

- - ← ZurĂŒck zur Trainingsplanung - -

- + 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} /> setPlanningPeekCtx(null)} /> -
+ ) }