From 4588ef4c7eb6e02d75f261bf6e9d024dd6ef2059 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 20 May 2026 07:42:46 +0200 Subject: [PATCH] Refactor navigation components and enhance return context handling - Replaced `PageReturnLink` with `PageReturnButton` for consistent back navigation across various pages. - Updated multiple components, including `ExercisePeekModal`, `PageFormEditorChrome`, and `ExerciseDetailPage`, to utilize the new return context features. - Enhanced CSS styles for the new return button to improve visual consistency. - Improved navigation logic in `TrainingFrameworkProgramEditPage` and `TrainingModuleEditPage` to ensure seamless user experience when navigating back to previous locations. --- .../docs/technical/NAV_RETURN_CONTEXT_SPEC.md | 46 +++++++++--- frontend/src/app.css | 15 ++++ frontend/src/components/ExercisePeekModal.jsx | 28 +++++-- frontend/src/components/NavStateLink.jsx | 12 +++ .../src/components/PageFormEditorChrome.jsx | 19 +++-- frontend/src/components/PageReturnButton.jsx | 30 ++++++++ frontend/src/components/PageReturnLink.jsx | 36 --------- .../exercises/ExerciseFormPageRoot.jsx | 37 ++++++---- .../components/exercises/ExerciseListCard.jsx | 28 +++++-- .../exercises/ExercisesListPageRoot.jsx | 10 ++- .../TrainingPublishToFrameworkModal.jsx | 6 +- frontend/src/hooks/useNavReturn.js | 19 +++++ frontend/src/pages/Dashboard.jsx | 42 ++++++++--- frontend/src/pages/ExerciseDetailPage.jsx | 41 +++++++---- frontend/src/pages/MediaLibraryPage.jsx | 21 ++++-- frontend/src/pages/SettingsLegalPage.jsx | 6 +- frontend/src/pages/SettingsSystemInfoPage.jsx | 7 +- frontend/src/pages/TrainingCoachPage.jsx | 24 ++++-- .../TrainingFrameworkProgramEditPage.jsx | 34 ++++++--- .../TrainingFrameworkProgramsListPage.jsx | 24 +++--- frontend/src/pages/TrainingModuleEditPage.jsx | 4 +- .../src/pages/TrainingModulesListPage.jsx | 18 +++-- .../pages/TrainingPlanTemplateEditPage.jsx | 23 ++++-- .../pages/TrainingPlanTemplatesListPage.jsx | 14 ++-- frontend/src/pages/TrainingUnitEditPage.jsx | 17 +++-- frontend/src/pages/TrainingUnitRunPage.jsx | 38 +++++++--- frontend/src/utils/navReturnContext.js | 73 +++++++++++++++++++ frontend/src/utils/navReturnContext.test.js | 15 ++++ 28 files changed, 497 insertions(+), 190 deletions(-) create mode 100644 frontend/src/components/NavStateLink.jsx create mode 100644 frontend/src/components/PageReturnButton.jsx delete mode 100644 frontend/src/components/PageReturnLink.jsx create mode 100644 frontend/src/hooks/useNavReturn.js diff --git a/.claude/docs/technical/NAV_RETURN_CONTEXT_SPEC.md b/.claude/docs/technical/NAV_RETURN_CONTEXT_SPEC.md index b196944..878ec7c 100644 --- a/.claude/docs/technical/NAV_RETURN_CONTEXT_SPEC.md +++ b/.claude/docs/technical/NAV_RETURN_CONTEXT_SPEC.md @@ -1,7 +1,7 @@ # Navigation — Return-Kontext (Rücksprung) **Stand:** 2026-05-20 -**Status:** Spezifikation + schrittweise Umsetzung (Pilot) +**Status:** Spezifikation + Phase 1–2 umgesetzt **Ziel:** In der PWA (ohne Browser-Back) zuverlässig an den fachlichen Ausgangspunkt zurückkehren — inkl. sinnvollem Label und optional UI-State. --- @@ -52,6 +52,13 @@ Router-State-Schlüssel: **`appReturn`** | `exerciseList` | — | `/exercises` (Filter/Auswahl via sessionStorage) | | `planningHub` | `buildPlanningHubReturnState(...)` | `planningHubPathFromReturnState(payload)` | | `trainingModulesList` | — | `/planning/training-modules` | +| `planTemplatesList` | — | `/planning/plan-templates` | +| `frameworkProgramsList` | — | `/planning/framework-programs` | +| `settings` | — | `/settings` | +| `dashboard` | — | `/` | +| `mediaLibrary` | — | `/media` | +| `trainingRun` | `{ unitId }` | `/planning/run/:unitId` | +| `currentLocation` | — | aktuelle Route (z. B. Einheiten-Editor) | | (frei) | — | `path` direkt gesetzt | ### Legacy-Kompatibilität @@ -76,14 +83,23 @@ Zentrale Datei: `frontend/src/utils/navReturnContext.js` | `navigateWithAppReturn(navigate, to, returnContext, options?)` | Navigation mit gesetztem `appReturn` | | `preserveAppReturnOnNavigate(navigate, location, to, options?)` | Weiterleiten, bestehenden Kontext behalten (z. B. nach `replace`) | -UI-Komponente: **`PageReturnLink`** — einheitlicher Zurück-Link oben auf Editor-/Detailseiten. +UI-Komponente: **`PageReturnButton`** — app-typischer Zurück-Schalter (Button mit Pfeil, kein Router-Link). +Links **zum** Ziel: **`NavStateLink`** mit `returnContext` der Quellseite. + +### Editor-Aktionen + +Auf Vollseiten-Editoren mit **`PageFormEditorChrome`** oder **`FormActionBar`** (`placement="bottom"`): + +- **Abbrechen** → `goBack()` / `goNavReturn(...)` (Einsprungspunkt, nicht feste Route) +- **Speichern & Schließen** → nach erfolgreichem Save ebenfalls `goBack()` +- Sticky Action Bar unten nutzen, wo vorhanden --- ## Regeln für Entwickler 1. **Jede Navigation** von Kontext A zu Editor B, wo der Nutzer „weitermachen“ soll, setzt `appReturn` (oder nutzt `navigateWithAppReturn`). -2. **Zielseite** zeigt `PageReturnLink` mit sinnvollem **Default-Fallback** (Bibliothek/Hub). +2. **Zielseite** zeigt `PageReturnButton` mit sinnvollem **Default-Fallback** (Bibliothek/Hub). 3. **Nach Create + `replace: true`:** Return-Kontext mit `preserveAppReturnOnNavigate` erhalten. 4. **Modals:** Schließen reicht; Redirect nach Speichern = Seiten-Navigation → Return setzen. 5. **Kein Return-Kontext** in `location.state` für interne Bibliothek → Detail → Bearbeiten, wenn Herkunft = offensichtliche Elternliste (Default-Fallback genügt). @@ -91,20 +107,30 @@ UI-Komponente: **`PageReturnLink`** — einheitlicher Zurück-Link oben auf Edit --- -## Pilot-Umsetzung (Phase 1) +## Umsetzungsstand + +### Phase 1 (Pilot) - [x] Spec + Utility + Tests -- [x] `PageReturnLink` -- [x] Übungsliste → Modul speichern → Modul-Editor (dynamischer Zurück-Link) +- [x] `PageReturnButton` (ersetzt Link-Variante) +- [x] Übungsliste → Modul speichern → Modul-Editor - [x] Planung: `SaveExercisesAsModuleModal` leitet Return-Kontext weiter - [x] `TrainingUnitEditPage`: `goBack` über `goNavReturn` (Legacy-bridge) -## Folge-Phasen (noch offen) +### Phase 2 (Flows verbinden) -- Weitere Editoren (Übung, Vorlage, Rahmenprogramm) -- Optional: globaler Zurück-Button in App-Chrome (Mobile) +- [x] Listen → Editoren: Übungen, Module, Vorlagen, Rahmenprogramme +- [x] Dashboard → Übung bearbeiten / Trainingsablauf / Einheit bearbeiten +- [x] Einstellungen-Unterseiten (Rechtliches, Systeminfo) +- [x] Trainingsablauf + Coach-Modus (`trainingRun`, Planungs-Fallback) +- [x] Medienbibliothek → verknüpfte Übungen/Einheiten +- [x] `ExercisePeekModal` → Vollseite mit Return +- [x] Editoren: Abbrechen + Speichern & Schließen → Einsprungspunkt + +### Optional (später) + +- Globaler Zurück-Button in App-Chrome (Mobile) - Nach Speichern: explizite Aktion „Zurück zum Ausgang“ im Toast -- `ExercisePeekModal` → Vollseite mit Return --- diff --git a/frontend/src/app.css b/frontend/src/app.css index 48bb744..05f59c5 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1466,6 +1466,21 @@ html.modal-scroll-locked .app-main { font-weight: 600; text-decoration: none; } +.page-return-btn { + display: inline-flex; + align-items: center; + gap: 6px; + margin-bottom: 0.75rem; + max-width: 100%; +} +.page-return-btn__icon { + flex-shrink: 0; +} +.page-return-btn span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .page-return-link:hover { text-decoration: underline; } diff --git a/frontend/src/components/ExercisePeekModal.jsx b/frontend/src/components/ExercisePeekModal.jsx index 9fae195..0afc490 100644 --- a/frontend/src/components/ExercisePeekModal.jsx +++ b/frontend/src/components/ExercisePeekModal.jsx @@ -5,6 +5,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom' import api from '../utils/api' +import NavStateLink from './NavStateLink' import ExerciseRichTextBlock from './ExerciseRichTextBlock' import CombinationPlanBracket from './CombinationPlanBracket' import { effectiveComboMethodProfile } from '../utils/comboPlanningMethodProfile' @@ -36,6 +37,8 @@ export default function ExercisePeekModal({ titleFallback, /** Nur Planung: effektives method_profile aus Zeilen-Katalog + Planungs-Override */ peekExtras, + /** Rücksprung-Kontext für „Vollständige Übungsseite“ */ + returnContext, }) { const [loading, setLoading] = useState(false) const [err, setErr] = useState(null) @@ -255,13 +258,24 @@ export default function ExercisePeekModal({ {top?.exerciseId != null ? (
- - Vollständige Übungsseite öffnen - + {returnContext ? ( + + Vollständige Übungsseite öffnen + + ) : ( + + Vollständige Übungsseite öffnen + + )}
) : null} diff --git a/frontend/src/components/NavStateLink.jsx b/frontend/src/components/NavStateLink.jsx new file mode 100644 index 0000000..4df41fb --- /dev/null +++ b/frontend/src/components/NavStateLink.jsx @@ -0,0 +1,12 @@ +import React from 'react' +import { Link } from 'react-router-dom' +import { linkStateWithAppReturn } from '../utils/navReturnContext' + +/** Router-Link mit appReturn im State (Rücksprung vom Zielscreen). */ +export default function NavStateLink({ to, returnContext, state, children, ...rest }) { + return ( + + {children} + + ) +} diff --git a/frontend/src/components/PageFormEditorChrome.jsx b/frontend/src/components/PageFormEditorChrome.jsx index 57151be..efb02dc 100644 --- a/frontend/src/components/PageFormEditorChrome.jsx +++ b/frontend/src/components/PageFormEditorChrome.jsx @@ -1,27 +1,30 @@ import React from 'react' -import { Link } from 'react-router-dom' import { useFormEditorActions } from '../context/FormEditorActionsContext' +import PageReturnButton from './PageReturnButton' /** - * Vollseiten-Editor: Zurück/Titel oben; FormActionBar fix unten (alle Viewports via FormEditorBottomSlot). + * Vollseiten-Editor: Zurück-Schalter + Titel oben; FormActionBar fix unten (FormEditorBottomSlot). */ export default function PageFormEditorChrome({ title, - backTo, - backLabel = 'Zurück', + fallbackPath, + fallbackLabel, actionConfig, children, testId, + showReturn = true, }) { useFormEditorActions(actionConfig) return (
- {backTo ? ( - - ← {backLabel} - + {showReturn && fallbackPath && fallbackLabel ? ( + ) : null}

{title}

diff --git a/frontend/src/components/PageReturnButton.jsx b/frontend/src/components/PageReturnButton.jsx new file mode 100644 index 0000000..8c58b07 --- /dev/null +++ b/frontend/src/components/PageReturnButton.jsx @@ -0,0 +1,30 @@ +import React, { useMemo } from 'react' +import { ArrowLeft } from 'lucide-react' +import { useNavReturn } from '../hooks/useNavReturn' + +/** + * App-typischer Zurück-Schalter (kein Router-Link) — nutzt appReturn / History / Fallback. + */ +export default function PageReturnButton({ + fallbackPath, + fallbackLabel, + className = 'page-return-btn btn btn-secondary btn-small', +}) { + const fallback = useMemo( + () => + fallbackPath && fallbackLabel + ? { path: fallbackPath, label: fallbackLabel } + : null, + [fallbackPath, fallbackLabel] + ) + const { goBack, target } = useNavReturn(fallback) + + if (!target?.label) return null + + return ( + + ) +} diff --git a/frontend/src/components/PageReturnLink.jsx b/frontend/src/components/PageReturnLink.jsx deleted file mode 100644 index 87d4975..0000000 --- a/frontend/src/components/PageReturnLink.jsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react' -import { useLocation, useNavigate } from 'react-router-dom' -import { goNavReturn, resolveNavReturnTarget } from '../utils/navReturnContext' - -/** - * Einheitlicher Zurück-Link für Editor-/Detailseiten (PWA-sicher). - */ -export default function PageReturnLink({ - fallbackPath, - fallbackLabel, - className = 'page-return-link', - style, -}) { - const location = useLocation() - const navigate = useNavigate() - const target = resolveNavReturnTarget(location, { - path: fallbackPath, - label: fallbackLabel, - }) - - if (!target?.path || !target?.label) return null - - const handleClick = (e) => { - e.preventDefault() - goNavReturn(navigate, location, { - path: fallbackPath, - label: fallbackLabel, - }) - } - - return ( - - ← {target.label} - - ) -} diff --git a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx index 2e1aa9d..95e0226 100644 --- a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx +++ b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react' -import { useNavigate, useParams, Link } from 'react-router-dom' +import { useNavigate, useParams, Link, useLocation } from 'react-router-dom' import api, { buildExerciseApiPayload } from '../../utils/api' import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../../utils/exerciseMediaUrl' import RichTextEditor from '../RichTextEditor' @@ -27,6 +27,14 @@ import { readSlotProfilesV1, normalizeAdvanceMode, parseComboRepSeriesCountUi } import { GripVertical } from 'lucide-react' import UnsavedChangesPrompt from '../UnsavedChangesPrompt' import PageFormEditorChrome from '../PageFormEditorChrome' +import { useNavReturn } from '../../hooks/useNavReturn' +import { + EXERCISES_LIST_PATH, + buildCurrentLocationReturnContext, + buildExercisesListReturnContext, + linkStateWithAppReturn, + preserveAppReturnOnNavigate, +} from '../../utils/navReturnContext' import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../../hooks/useUnsavedChangesBlocker' const INTENSITY_OPTIONS = [ @@ -469,6 +477,9 @@ function MultiAssocBlock({ title, rows, setRows, options, idKey, emptyLabel }) { function ExerciseFormPageRoot() { const { id: routeId } = useParams() const navigate = useNavigate() + const location = useLocation() + const exercisesListReturn = useMemo(() => buildExercisesListReturnContext(), []) + const { goBack } = useNavReturn(exercisesListReturn) const { user } = useAuth() const isSuperadmin = user?.role === 'superadmin' const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' @@ -941,16 +952,16 @@ function ExerciseFormPageRoot() { setVariants((ex.variants || []).map(apiVariantToRow)) setFormDirty(false) toast.success('Gespeichert.') - if (closeAfter) navigate('/exercises') + if (closeAfter) goBack() return true } const created = await api.createExercise(payload) setFormDirty(false) toast.success('Übung angelegt.') if (closeAfter) { - navigate('/exercises') + goBack() } else if (!fromUnsavedDialog) { - navigate(`/exercises/${created.id}/edit`, { replace: true }) + preserveAppReturnOnNavigate(navigate, location, `/exercises/${created.id}/edit`, { replace: true }) } return true } catch (err) { @@ -960,7 +971,7 @@ function ExerciseFormPageRoot() { setSaving(false) } }, - [exerciseId, formData, isEdit, navigate, toast], + [exerciseId, formData, isEdit, navigate, location, toast, goBack], ) const handleSubmit = useCallback( @@ -979,10 +990,6 @@ function ExerciseFormPageRoot() { [performSaveAttempt], ) - const goBackToList = useCallback(() => { - navigate('/exercises') - }, [navigate]) - const actionConfig = useMemo( () => ({ formId: 'exercise-form', @@ -990,11 +997,11 @@ function ExerciseFormPageRoot() { isNew: !isEdit, onSave: handleSubmit, onSaveAndClose: handleSaveAndClose, - onCancel: goBackToList, + onCancel: goBack, showSave: true, showSaveAndClose: true, }), - [saving, isEdit, handleSubmit, handleSaveAndClose, goBackToList], + [saving, isEdit, handleSubmit, handleSaveAndClose, goBack], ) const handleUnsavedDialogSave = async () => { @@ -1198,15 +1205,17 @@ function ExerciseFormPageRoot() { {isEdit ? (

diff --git a/frontend/src/components/exercises/ExerciseListCard.jsx b/frontend/src/components/exercises/ExerciseListCard.jsx index 84e7bc8..a0d1aee 100644 --- a/frontend/src/components/exercises/ExerciseListCard.jsx +++ b/frontend/src/components/exercises/ExerciseListCard.jsx @@ -1,5 +1,10 @@ -import React from 'react' -import { Link, useNavigate } from 'react-router-dom' +import React, { useMemo } from 'react' +import { useNavigate } from 'react-router-dom' +import NavStateLink from '../NavStateLink' +import { + buildExercisesListReturnContext, + navigateWithAppReturn, +} from '../../utils/navReturnContext' import { Eye, Pencil, @@ -141,12 +146,14 @@ export default function ExerciseListCard({ selectionPinned = false, }) { const navigate = useNavigate() + const listReturn = useMemo(() => buildExercisesListReturnContext(), []) const focusNames = exerciseFocusNames(exercise) const styleNames = coerceApiNameList(exercise.style_direction_names) const typeNames = coerceApiNameList(exercise.training_type_names) const titleText = (exercise.title || 'Übung').replace(/"/g, '') - const openExercisePage = () => navigate(`/exercises/${exercise.id}`) + const openExercisePage = () => + navigateWithAppReturn(navigate, `/exercises/${exercise.id}`, listReturn) const handleBodyClick = (e) => { if (e.target.closest('a, button, input, textarea, select, label, [role="button"]')) return @@ -186,9 +193,13 @@ export default function ExerciseListCard({ aria-label={`„${titleText}“ öffnen`} >

- e.stopPropagation()}> + e.stopPropagation()} + > {exercise.title} - + {selectionPinned ? ( Auswahl @@ -246,14 +257,15 @@ export default function ExerciseListCard({ > - - + {canUserRequestExerciseDelete(user, exercise) ? (

) : null} @@ -205,9 +217,13 @@ function Dashboard() {