From ef4dd933248cf6d9b08e2ec96185224f92016045 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 20 May 2026 06:56:58 +0200 Subject: [PATCH 1/6] Enhance exercise card interactivity and toolbar functionality - Added clickable behavior to exercise card body, allowing users to navigate to exercise details by clicking anywhere on the card. - Introduced keyboard accessibility for exercise cards, enabling navigation via Enter and Space keys. - Updated ExerciseListBulkToolbar to include a new button for saving selected exercises as a module, enhancing bulk action capabilities. - Improved CSS styles for clickable exercise card body to indicate interactivity. --- frontend/src/app.css | 8 + .../exercises/ExerciseListBulkToolbar.jsx | 4 + .../components/exercises/ExerciseListCard.jsx | 47 ++- .../exercises/ExercisesListPageRoot.jsx | 45 ++- .../SaveSelectedExercisesAsModuleModal.jsx | 318 ++++++++++++++++++ .../src/utils/exerciseListSessionState.js | 48 +++ 6 files changed, 459 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/exercises/SaveSelectedExercisesAsModuleModal.jsx create mode 100644 frontend/src/utils/exerciseListSessionState.js diff --git a/frontend/src/app.css b/frontend/src/app.css index f5bcf83..566fc98 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -2953,6 +2953,14 @@ html.modal-scroll-locked .app-main { flex: 1; min-width: 0; } +.exercise-card__body--clickable { + cursor: pointer; +} +.exercise-card__body--clickable:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 8px; +} .exercise-card-title { margin: 0 0 8px; font-size: 1.05rem; diff --git a/frontend/src/components/exercises/ExerciseListBulkToolbar.jsx b/frontend/src/components/exercises/ExerciseListBulkToolbar.jsx index 084dcd3..1fd134d 100644 --- a/frontend/src/components/exercises/ExerciseListBulkToolbar.jsx +++ b/frontend/src/components/exercises/ExerciseListBulkToolbar.jsx @@ -5,6 +5,7 @@ export default function ExerciseListBulkToolbar({ bulkMaxIds, onClearSelection, onOpenBulkModal, + onOpenSaveModuleModal, }) { if (selectedCount < 1) return null @@ -14,6 +15,9 @@ export default function ExerciseListBulkToolbar({ + diff --git a/frontend/src/components/exercises/ExerciseListCard.jsx b/frontend/src/components/exercises/ExerciseListCard.jsx index 234c69d..c375723 100644 --- a/frontend/src/components/exercises/ExerciseListCard.jsx +++ b/frontend/src/components/exercises/ExerciseListCard.jsx @@ -1,5 +1,5 @@ import React from 'react' -import { Link } from 'react-router-dom' +import { Link, useNavigate } from 'react-router-dom' import { Eye, Pencil, @@ -131,10 +131,27 @@ function ExerciseCardScopeStatus({ exercise }) { /** * Kartenzeile in der Übungsliste (Fokus/Planung — keine Virtualisierung im Grid, dafür content-visibility in app.css). */ -export default function ExerciseListCard({ exercise, user, selectedIds, toggleSelect, onDelete }) { +export default function ExerciseListCard({ exercise, user, selectedIds, toggleSelect, onDelete, onPeek }) { + const navigate = useNavigate() 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 handleBodyClick = (e) => { + if (e.target.closest('a, button, input, textarea, select, label, [role="button"]')) return + openExercisePage() + } + + const handleBodyKeyDown = (e) => { + if (e.key !== 'Enter' && e.key !== ' ') return + if (e.target.closest('a, button, input, textarea, select, label, [role="button"]')) return + e.preventDefault() + openExercisePage() + } + return (
@@ -142,12 +159,21 @@ export default function ExerciseListCard({ exercise, user, selectedIds, toggleSe type="checkbox" checked={selectedIds.has(Number(exercise.id))} onChange={() => toggleSelect(exercise.id)} - aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`} + aria-label={`„${titleText}“ auswählen`} className="exercise-card-layout__check" /> -
+

- {exercise.title} + e.stopPropagation()}> + {exercise.title} +

{focusNames.map((name) => ( @@ -191,14 +217,15 @@ export default function ExerciseListCard({ exercise, user, selectedIds, toggleSe
- onPeek?.(exercise)} > - + { if (!user?.id) return if (prefsAppliedRef.current) return + const session = readExerciseListSessionState() const merged = mergeExerciseListPrefsFromApi(user.exercise_list_prefs) - setFilters(applyDashboardExerciseListUrl(merged)) + const filtersFromSession = session?.filters + setFilters(applyDashboardExerciseListUrl(filtersFromSession ?? merged)) + if (session) { + setSearchInput(session.searchInput || '') + setAiSearchInput(session.aiSearchInput || '') + setMineOnly(session.mineOnly) + } try { const sp = new URLSearchParams(window.location.search) if (sp.get('mine') === '1' || sp.get('created_by_me') === '1') setMineOnly(true) @@ -81,6 +93,16 @@ function ExercisesListPageRoot() { prefsAppliedRef.current = true }, [user?.id, user?.exercise_list_prefs]) + useEffect(() => { + if (!prefsAppliedRef.current) return + writeExerciseListSessionState({ + filters, + searchInput, + aiSearchInput, + mineOnly, + }) + }, [filters, searchInput, aiSearchInput, mineOnly]) + useEffect(() => { if (!user?.id) prefsAppliedRef.current = false }, [user?.id]) @@ -247,6 +269,11 @@ function ExercisesListPageRoot() { [exercises, selectedIds] ) + const selectedExercisesInListOrder = useMemo( + () => exercises.filter((e) => selectedIds.has(Number(e.id))), + [exercises, selectedIds] + ) + const bulkVisibilityOptions = useMemo(() => { const base = [ { id: '', label: '— nicht ändern —' }, @@ -480,6 +507,7 @@ function ExercisesListPageRoot() { bulkMaxIds={BULK_MAX_IDS} onClearSelection={clearSelection} onOpenBulkModal={openBulkModal} + onOpenSaveModuleModal={() => setSaveModuleModalOpen(true)} /> + setSaveModuleModalOpen(false)} + selectedExercises={selectedExercisesInListOrder} + onSuccess={clearSelection} + /> + + setPeekExercise(null)} + /> + {listFetching && exercises.length === 0 ? (
@@ -569,6 +611,7 @@ function ExercisesListPageRoot() { selectedIds={selectedIds} toggleSelect={toggleSelect} onDelete={handleDelete} + onPeek={(ex) => setPeekExercise({ id: ex.id, title: ex.title })} /> ))}
diff --git a/frontend/src/components/exercises/SaveSelectedExercisesAsModuleModal.jsx b/frontend/src/components/exercises/SaveSelectedExercisesAsModuleModal.jsx new file mode 100644 index 0000000..1fddf41 --- /dev/null +++ b/frontend/src/components/exercises/SaveSelectedExercisesAsModuleModal.jsx @@ -0,0 +1,318 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import FormActionBar from '../FormActionBar' +import FormModalOverlay from '../FormModalOverlay' +import api from '../../utils/api' +import { useToast } from '../../context/ToastContext' +import { useAuth } from '../../context/AuthContext' +import { activeClubMemberships, getDefaultClubIdForGovernanceForms } from '../../utils/activeClub' +import { hydrateExercisePlanningRow } from '../../utils/trainingUnitSectionsForm' + +/** + * Erstellt ein Trainingsmodul aus per Checkbox ausgewählten Übungen (Reihenfolge = Listenreihenfolge). + */ +export default function SaveSelectedExercisesAsModuleModal({ + open, + onClose, + /** @type {Array<{ id: number, title?: string }>} */ + selectedExercises, + onSuccess, +}) { + const navigate = useNavigate() + const toast = useToast() + const { user } = useAuth() + const memberClubs = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs]) + const roleLc = String(user?.role || '').toLowerCase() + const isSuperadmin = roleLc === 'superadmin' + + const [loading, setLoading] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [loadErr, setLoadErr] = useState('') + /** @type {[Array, Function]} */ + const [rows, setRows] = useState([]) + + const [title, setTitle] = useState('') + const [visibility, setVisibility] = useState('club') + const [clubId, setClubId] = useState('') + + const resetLocal = useCallback(() => { + setLoadErr('') + setRows([]) + setTitle('') + setVisibility('club') + setClubId('') + }, []) + + useEffect(() => { + if (!open) { + resetLocal() + return + } + const defaultClub = getDefaultClubIdForGovernanceForms(user) + if (defaultClub != null) setClubId(String(defaultClub)) + else if (memberClubs.length === 1) setClubId(String(memberClubs[0].id)) + + const count = selectedExercises?.length || 0 + setTitle(count > 0 ? `Modul · ${count} Übung${count === 1 ? '' : 'en'}` : '') + + if (!count) { + setRows([]) + return + } + + let cancelled = false + setLoading(true) + setLoadErr('') + ;(async () => { + try { + const hydrated = [] + for (const ex of selectedExercises) { + const row = await hydrateExercisePlanningRow({ id: ex.id, title: ex.title }) + if (row) hydrated.push(row) + } + if (cancelled) return + if (!hydrated.length) { + setLoadErr('Ausgewählte Übungen konnten nicht geladen werden.') + setRows([]) + return + } + setRows(hydrated) + } catch (e) { + if (!cancelled) { + setLoadErr(e.message || 'Übungen konnten nicht geladen werden') + setRows([]) + } + } finally { + if (!cancelled) setLoading(false) + } + })() + + return () => { + cancelled = true + } + }, [open, selectedExercises, user, memberClubs.length, resetLocal]) + + const updateRow = (idx, patch) => { + setRows((prev) => prev.map((row, i) => (i === idx ? { ...row, ...patch } : row))) + } + + const handleSubmit = async (e) => { + e.preventDefault() + if (submitting || !rows.length) return + + const tit = (title || '').trim() + if (!tit) { + toast.error('Bitte einen Modultitel angeben.') + return + } + + const itemsPayload = rows.map((row, oi) => ({ + item_type: 'exercise', + order_index: oi, + exercise_id: row.exercise_id, + exercise_variant_id: + row.exercise_kind === 'combination' || + row.exercise_variant_id === '' || + row.exercise_variant_id == null + ? null + : Number(row.exercise_variant_id), + planned_duration_min: + row.planned_duration_min !== '' && row.planned_duration_min != null + ? Number(row.planned_duration_min) + : null, + notes: row.notes != null && String(row.notes).trim() ? String(row.notes).trim() : null, + })) + + let cid = visibility === 'club' && clubId ? parseInt(clubId, 10) : null + if (visibility === 'club' && (!Number.isFinite(cid) || cid < 1)) { + const fallback = getDefaultClubIdForGovernanceForms(user) + if (Number.isFinite(fallback) && fallback > 0) cid = fallback + } + if (visibility === 'club' && (!Number.isFinite(cid) || cid < 1)) { + toast.error('Bitte einen Verein wählen (Sichtbarkeit „Verein“).') + return + } + if (visibility !== 'club') cid = null + + setSubmitting(true) + try { + const created = await api.createTrainingModule({ + title: tit, + visibility, + club_id: cid, + items: itemsPayload, + }) + toast.success('Trainingsmodul gespeichert.') + if (created?.id) { + navigate(`/planning/training-modules/${created.id}`) + } + onSuccess?.() + onClose() + } catch (err) { + toast.error(err.message || 'Speichern fehlgeschlagen') + } finally { + setSubmitting(false) + } + } + + if (!open) return null + + return ( + +
+

Auswahl als Trainingsmodul

+

+ Die gewählten Übungen werden in der Reihenfolge der Liste als Modulpositionen + übernommen. Pro Übung kann optional eine Variante gesetzt werden. +

+ + {loading ? ( +

Laden …

+ ) : loadErr ? ( +

{loadErr}

+ ) : rows.length === 0 ? ( +

Keine Übungen ausgewählt.

+ ) : ( +
+
+
+ + setTitle(e.target.value)} + required + placeholder="z. B. Technikblock Grundlagen" + /> +
+ +
+
    + {rows.map((row, idx) => { + const isCombo = row.exercise_kind === 'combination' + const variants = Array.isArray(row.variants) ? row.variants : [] + return ( +
  • +
    + {idx + 1}. {(row.exercise_title || '').trim() || `Übung #${row.exercise_id}`} + {isCombo ? ( + + Kombination + + ) : null} +
    + {!isCombo && variants.length > 0 ? ( +
    + + +
    + ) : !isCombo ? ( +
    Keine Varianten hinterlegt
    + ) : null} +
  • + ) + })} +
+
+ +
+ + +
+ {visibility === 'club' ? ( +
+ + +
+ ) : null} +
+ + + + )} + + {loading ? ( +
+ +
+ ) : null} + + {!loading && (loadErr || rows.length === 0) ? ( +
+ +
+ ) : null} +
+
+ ) +} diff --git a/frontend/src/utils/exerciseListSessionState.js b/frontend/src/utils/exerciseListSessionState.js new file mode 100644 index 0000000..5822aca --- /dev/null +++ b/frontend/src/utils/exerciseListSessionState.js @@ -0,0 +1,48 @@ +import { INITIAL_EXERCISE_LIST_FILTERS, mergeExerciseListPrefsFromApi } from '../constants/exerciseListFilters' + +const STORAGE_KEY = 'shinkan.exerciseList.session.v1' + +function safeParse(raw) { + if (!raw) return null + try { + return JSON.parse(raw) + } catch { + return null + } +} + +/** @returns {{ filters: object, searchInput: string, aiSearchInput: string, mineOnly: boolean } | null} */ +export function readExerciseListSessionState() { + if (typeof sessionStorage === 'undefined') return null + const parsed = safeParse(sessionStorage.getItem(STORAGE_KEY)) + if (!parsed || typeof parsed !== 'object') return null + + const filters = + parsed.filters && typeof parsed.filters === 'object' + ? mergeExerciseListPrefsFromApi(parsed.filters) + : { ...INITIAL_EXERCISE_LIST_FILTERS } + + return { + filters, + searchInput: typeof parsed.searchInput === 'string' ? parsed.searchInput : '', + aiSearchInput: typeof parsed.aiSearchInput === 'string' ? parsed.aiSearchInput : '', + mineOnly: !!parsed.mineOnly, + } +} + +export function writeExerciseListSessionState(state) { + if (typeof sessionStorage === 'undefined' || !state || typeof state !== 'object') return + try { + sessionStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + filters: state.filters ?? { ...INITIAL_EXERCISE_LIST_FILTERS }, + searchInput: typeof state.searchInput === 'string' ? state.searchInput : '', + aiSearchInput: typeof state.aiSearchInput === 'string' ? state.aiSearchInput : '', + mineOnly: !!state.mineOnly, + }) + ) + } catch { + /* quota / private mode */ + } +} -- 2.43.0 From 14b005e9b801a2fa1722d7454e23c95effb27f74 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 20 May 2026 07:03:15 +0200 Subject: [PATCH 2/6] Enhance exercise selection and display features - Introduced new CSS styles for exercise cards and selection sections, improving visual feedback for selected exercises. - Updated ExerciseListCard to support a new `selectionPinned` prop, allowing for a badge display on selected exercises. - Refactored selection handling in ExercisesListPageRoot to manage selected entries more effectively, replacing the previous Set-based approach. - Enhanced SaveSelectedExercisesAsModuleModal to support appending exercises to existing modules, improving module management capabilities. - Updated session state handling to include selected entries, ensuring persistence across sessions. --- frontend/src/app.css | 43 ++++ .../components/exercises/ExerciseListCard.jsx | 26 +- .../exercises/ExercisesListPageRoot.jsx | 171 ++++++++---- .../SaveSelectedExercisesAsModuleModal.jsx | 243 ++++++++++++------ frontend/src/utils/exerciseListSelection.js | 90 +++++++ .../src/utils/exerciseListSessionState.js | 5 +- 6 files changed, 440 insertions(+), 138 deletions(-) create mode 100644 frontend/src/utils/exerciseListSelection.js diff --git a/frontend/src/app.css b/frontend/src/app.css index 566fc98..65d308e 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -2949,6 +2949,45 @@ html.modal-scroll-locked .app-main { flex-shrink: 0; accent-color: var(--accent); } +.exercise-card--selection-pinned { + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 18%, transparent); +} +.exercise-card__selection-badge { + display: inline-flex; + align-items: center; + margin-left: 8px; + padding: 2px 7px; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--accent-dark); + background: var(--accent-soft); + vertical-align: middle; +} +.exercises-selection-section { + margin-bottom: 1rem; +} +.exercises-selection-section__head { + margin-bottom: 0.65rem; +} +.exercises-selection-section__title { + margin: 0; + font-size: 1rem; + font-weight: 700; + color: var(--text1); +} +.exercises-selection-section__hint { + margin: 4px 0 0; + font-size: 0.85rem; + color: var(--text3); + line-height: 1.45; +} +.exercises-list-grid--selection { + margin-bottom: 0.25rem; +} .exercise-card-body-flex { flex: 1; min-width: 0; @@ -2966,6 +3005,10 @@ html.modal-scroll-locked .app-main { font-size: 1.05rem; line-height: 1.3; font-weight: 700; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 8px; } .exercise-card-title a { color: inherit; diff --git a/frontend/src/components/exercises/ExerciseListCard.jsx b/frontend/src/components/exercises/ExerciseListCard.jsx index c375723..84e7bc8 100644 --- a/frontend/src/components/exercises/ExerciseListCard.jsx +++ b/frontend/src/components/exercises/ExerciseListCard.jsx @@ -131,7 +131,15 @@ function ExerciseCardScopeStatus({ exercise }) { /** * Kartenzeile in der Übungsliste (Fokus/Planung — keine Virtualisierung im Grid, dafür content-visibility in app.css). */ -export default function ExerciseListCard({ exercise, user, selectedIds, toggleSelect, onDelete, onPeek }) { +export default function ExerciseListCard({ + exercise, + user, + selectedIds, + toggleSelect, + onDelete, + onPeek, + selectionPinned = false, +}) { const navigate = useNavigate() const focusNames = exerciseFocusNames(exercise) const styleNames = coerceApiNameList(exercise.style_direction_names) @@ -153,12 +161,19 @@ export default function ExerciseListCard({ exercise, user, selectedIds, toggleSe } return ( -
+
toggleSelect(exercise.id)} + onChange={() => toggleSelect(exercise)} aria-label={`„${titleText}“ auswählen`} className="exercise-card-layout__check" /> @@ -174,6 +189,11 @@ export default function ExerciseListCard({ exercise, user, selectedIds, toggleSe e.stopPropagation()}> {exercise.title} + {selectionPinned ? ( + + Auswahl + + ) : null}
{focusNames.map((name) => ( diff --git a/frontend/src/components/exercises/ExercisesListPageRoot.jsx b/frontend/src/components/exercises/ExercisesListPageRoot.jsx index 8a20a46..5bdfc84 100644 --- a/frontend/src/components/exercises/ExercisesListPageRoot.jsx +++ b/frontend/src/components/exercises/ExercisesListPageRoot.jsx @@ -14,6 +14,11 @@ import ExercisePeekModal from '../ExercisePeekModal' import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips' import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../../utils/exerciseListQuery' import { readExerciseListSessionState, writeExerciseListSessionState } from '../../utils/exerciseListSessionState' +import { + mergeSelectedWithListEntries, + normalizeSelectedEntries, + snapshotExerciseForSelection, +} from '../../utils/exerciseListSelection' import { useExerciseListCatalogsAndQuery } from '../../hooks/useExerciseListCatalogsAndQuery' import { INITIAL_EXERCISE_LIST_FILTERS, @@ -54,7 +59,7 @@ function ExercisesListPageRoot() { const [pageTab, setPageTab] = useState('list') const prefsAppliedRef = useRef(false) - const [selectedIds, setSelectedIds] = useState(() => new Set()) + const [selectedEntries, setSelectedEntries] = useState(() => []) const [bulkModalOpen, setBulkModalOpen] = useState(false) const [bulkVisibility, setBulkVisibility] = useState('') const [bulkStatus, setBulkStatus] = useState('') @@ -83,6 +88,7 @@ function ExercisesListPageRoot() { setSearchInput(session.searchInput || '') setAiSearchInput(session.aiSearchInput || '') setMineOnly(session.mineOnly) + setSelectedEntries(normalizeSelectedEntries(session.selectedEntries)) } try { const sp = new URLSearchParams(window.location.search) @@ -100,8 +106,9 @@ function ExercisesListPageRoot() { searchInput, aiSearchInput, mineOnly, + selectedEntries, }) - }, [filters, searchInput, aiSearchInput, mineOnly]) + }, [filters, searchInput, aiSearchInput, mineOnly, selectedEntries]) useEffect(() => { if (!user?.id) prefsAppliedRef.current = false @@ -142,9 +149,20 @@ function ExercisesListPageRoot() { loadMore, } = useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey }) - useEffect(() => { - setSelectedIds(new Set()) - }, [queryBase]) + const selectedIds = useMemo( + () => new Set(selectedEntries.map((e) => Number(e.id)).filter((id) => Number.isFinite(id) && id > 0)), + [selectedEntries] + ) + + const selectedExercisesDisplay = useMemo( + () => mergeSelectedWithListEntries(selectedEntries, exercises), + [selectedEntries, exercises] + ) + + const filterResultExercises = useMemo( + () => exercises.filter((e) => !selectedIds.has(Number(e.id))), + [exercises, selectedIds] + ) const focusOptions = useMemo( () => @@ -237,42 +255,46 @@ function ExercisesListPageRoot() { ? Number(user.active_club_id) : null - const toggleSelect = useCallback((id) => { - setSelectedIds((prev) => { - const n = new Set(prev) - const nid = Number(id) - if (Number.isNaN(nid)) return prev - if (n.has(nid)) n.delete(nid) - else n.add(nid) - return n + const toggleSelect = useCallback((exercise) => { + const snap = snapshotExerciseForSelection(exercise) + if (!snap) return + setSelectedEntries((prev) => { + const idx = prev.findIndex((e) => Number(e.id) === snap.id) + if (idx >= 0) return prev.filter((_, i) => i !== idx) + return [...prev, snap] }) }, []) - const clearSelection = useCallback(() => setSelectedIds(new Set()), []) + const clearSelection = useCallback(() => setSelectedEntries([]), []) const toggleSelectAllPage = useCallback(() => { - setSelectedIds((prev) => { - const n = new Set(prev) - const allSel = - exercises.length > 0 && exercises.every((e) => n.has(Number(e.id))) + setSelectedEntries((prev) => { + const ids = new Set(prev.map((e) => Number(e.id))) + const pageIds = filterResultExercises.map((e) => Number(e.id)) + const allSel = pageIds.length > 0 && pageIds.every((id) => ids.has(id)) if (allSel) { - exercises.forEach((e) => n.delete(Number(e.id))) - } else { - exercises.forEach((e) => n.add(Number(e.id))) + const remove = new Set(pageIds) + return prev.filter((e) => !remove.has(Number(e.id))) } - return n + const next = [...prev] + for (const ex of filterResultExercises) { + const snap = snapshotExerciseForSelection(ex) + if (!snap || ids.has(snap.id)) continue + ids.add(snap.id) + next.push(snap) + } + return next }) - }, [exercises]) + }, [filterResultExercises]) const allOnPageSelected = useMemo( - () => exercises.length > 0 && exercises.every((e) => selectedIds.has(Number(e.id))), - [exercises, selectedIds] + () => + filterResultExercises.length > 0 && + filterResultExercises.every((e) => selectedIds.has(Number(e.id))), + [filterResultExercises, selectedIds] ) - const selectedExercisesInListOrder = useMemo( - () => exercises.filter((e) => selectedIds.has(Number(e.id))), - [exercises, selectedIds] - ) + const selectedExercisesInListOrder = selectedExercisesDisplay const bulkVisibilityOptions = useMemo(() => { const base = [ @@ -289,6 +311,7 @@ function ExercisesListPageRoot() { try { await api.deleteExercise(exercise.id) setExercises((prev) => prev.filter((e) => e.id !== exercise.id)) + setSelectedEntries((prev) => prev.filter((e) => Number(e.id) !== Number(exercise.id))) } catch (err) { alert('Fehler beim Löschen: ' + err.message) } @@ -582,45 +605,81 @@ function ExercisesListPageRoot() { onClose={() => setPeekExercise(null)} /> - {listFetching && exercises.length === 0 ? ( + {listFetching && exercises.length === 0 && selectedEntries.length === 0 ? (

Lade Übungen…

- ) : exercises.length === 0 ? ( + ) : exercises.length === 0 && selectedEntries.length === 0 ? (

Keine Übungen gefunden.

) : ( <> - {listFetching ? ( -

Aktualisiere Treffer…

+ {selectedEntries.length > 0 ? ( +
+
+

Auswahl ({selectedEntries.length})

+

+ Bleibt sichtbar, auch wenn du den Filter wechselst — ideal für die Modul-Zusammenstellung. +

+
+
+ {selectedExercisesDisplay.map((exercise) => ( + setPeekExercise({ id: ex.id, title: ex.title })} + selectionPinned + /> + ))} +
+
) : null} -

- {exercises.length} angezeigt - {hasMore ? ' · es gibt weitere Einträge' : ''} -

-
- {exercises.map((exercise) => ( - setPeekExercise({ id: ex.id, title: ex.title })} - /> - ))} -
- {hasMore && ( -
- -
+ + {filterResultExercises.length === 0 ? ( + selectedEntries.length > 0 ? ( +

+ Keine weiteren Treffer für den aktuellen Filter. +

+ ) : null + ) : ( + <> + {listFetching ? ( +

Aktualisiere Treffer…

+ ) : null} +

+ {filterResultExercises.length} Treffer + {selectedEntries.length > 0 ? ' (ohne bereits Ausgewählte)' : ''} + {hasMore ? ' · es gibt weitere Einträge' : ''} +

+
+ {filterResultExercises.map((exercise) => ( + setPeekExercise({ id: ex.id, title: ex.title })} + /> + ))} +
+ {hasMore && ( +
+ +
+ )} + )} )} diff --git a/frontend/src/components/exercises/SaveSelectedExercisesAsModuleModal.jsx b/frontend/src/components/exercises/SaveSelectedExercisesAsModuleModal.jsx index 1fddf41..15dc269 100644 --- a/frontend/src/components/exercises/SaveSelectedExercisesAsModuleModal.jsx +++ b/frontend/src/components/exercises/SaveSelectedExercisesAsModuleModal.jsx @@ -7,9 +7,11 @@ import { useToast } from '../../context/ToastContext' import { useAuth } from '../../context/AuthContext' import { activeClubMemberships, getDefaultClubIdForGovernanceForms } from '../../utils/activeClub' import { hydrateExercisePlanningRow } from '../../utils/trainingUnitSectionsForm' +import { buildRowsPayload, moduleItemToPayload } from '../../utils/exerciseListSelection' /** - * Erstellt ein Trainingsmodul aus per Checkbox ausgewählten Übungen (Reihenfolge = Listenreihenfolge). + * Erstellt ein Trainingsmodul aus per Checkbox ausgewählten Übungen (Reihenfolge = Auswahlreihenfolge). + * Optional: Positionen an ein bestehendes Modul anfügen. */ export default function SaveSelectedExercisesAsModuleModal({ open, @@ -30,6 +32,11 @@ export default function SaveSelectedExercisesAsModuleModal({ const [loadErr, setLoadErr] = useState('') /** @type {[Array, Function]} */ const [rows, setRows] = useState([]) + const [moduleOptions, setModuleOptions] = useState([]) + const [modulesLoading, setModulesLoading] = useState(false) + + const [targetMode, setTargetMode] = useState('new') + const [existingModuleId, setExistingModuleId] = useState('') const [title, setTitle] = useState('') const [visibility, setVisibility] = useState('club') @@ -38,6 +45,9 @@ export default function SaveSelectedExercisesAsModuleModal({ const resetLocal = useCallback(() => { setLoadErr('') setRows([]) + setModuleOptions([]) + setTargetMode('new') + setExistingModuleId('') setTitle('') setVisibility('club') setClubId('') @@ -92,6 +102,27 @@ export default function SaveSelectedExercisesAsModuleModal({ } }, [open, selectedExercises, user, memberClubs.length, resetLocal]) + useEffect(() => { + if (!open) return + let cancelled = false + setModulesLoading(true) + api + .listTrainingModules() + .then((list) => { + if (cancelled) return + setModuleOptions(Array.isArray(list) ? list : []) + }) + .catch(() => { + if (!cancelled) setModuleOptions([]) + }) + .finally(() => { + if (!cancelled) setModulesLoading(false) + }) + return () => { + cancelled = true + } + }, [open]) + const updateRow = (idx, patch) => { setRows((prev) => prev.map((row, i) => (i === idx ? { ...row, ...patch } : row))) } @@ -100,47 +131,56 @@ export default function SaveSelectedExercisesAsModuleModal({ e.preventDefault() if (submitting || !rows.length) return - const tit = (title || '').trim() - if (!tit) { - toast.error('Bitte einen Modultitel angeben.') + const newItemsPayload = buildRowsPayload(rows) + if (!newItemsPayload.length) { + toast.error('Keine gültigen Übungspositionen.') return } - const itemsPayload = rows.map((row, oi) => ({ - item_type: 'exercise', - order_index: oi, - exercise_id: row.exercise_id, - exercise_variant_id: - row.exercise_kind === 'combination' || - row.exercise_variant_id === '' || - row.exercise_variant_id == null - ? null - : Number(row.exercise_variant_id), - planned_duration_min: - row.planned_duration_min !== '' && row.planned_duration_min != null - ? Number(row.planned_duration_min) - : null, - notes: row.notes != null && String(row.notes).trim() ? String(row.notes).trim() : null, - })) - - let cid = visibility === 'club' && clubId ? parseInt(clubId, 10) : null - if (visibility === 'club' && (!Number.isFinite(cid) || cid < 1)) { - const fallback = getDefaultClubIdForGovernanceForms(user) - if (Number.isFinite(fallback) && fallback > 0) cid = fallback - } - if (visibility === 'club' && (!Number.isFinite(cid) || cid < 1)) { - toast.error('Bitte einen Verein wählen (Sichtbarkeit „Verein“).') - return - } - if (visibility !== 'club') cid = null - setSubmitting(true) try { + if (targetMode === 'append') { + const mid = parseInt(existingModuleId, 10) + if (!Number.isFinite(mid) || mid < 1) { + toast.error('Bitte ein bestehendes Modul wählen.') + return + } + const existing = await api.getTrainingModule(mid) + const existingItems = Array.isArray(existing?.items) ? existing.items : [] + const merged = [ + ...existingItems.map((row, idx) => moduleItemToPayload(row, idx)).filter(Boolean), + ...newItemsPayload.map((row, idx) => ({ ...row, order_index: existingItems.length + idx })), + ] + await api.updateTrainingModule(mid, { items: merged }) + toast.success(`${newItemsPayload.length} Übung(en) an Modul angefügt.`) + navigate(`/planning/training-modules/${mid}`) + onSuccess?.() + onClose() + return + } + + const tit = (title || '').trim() + if (!tit) { + toast.error('Bitte einen Modultitel angeben.') + return + } + + let cid = visibility === 'club' && clubId ? parseInt(clubId, 10) : null + if (visibility === 'club' && (!Number.isFinite(cid) || cid < 1)) { + const fallback = getDefaultClubIdForGovernanceForms(user) + if (Number.isFinite(fallback) && fallback > 0) cid = fallback + } + if (visibility === 'club' && (!Number.isFinite(cid) || cid < 1)) { + toast.error('Bitte einen Verein wählen (Sichtbarkeit „Verein“).') + return + } + if (visibility !== 'club') cid = null + const created = await api.createTrainingModule({ title: tit, visibility, club_id: cid, - items: itemsPayload, + items: newItemsPayload, }) toast.success('Trainingsmodul gespeichert.') if (created?.id) { @@ -157,13 +197,15 @@ export default function SaveSelectedExercisesAsModuleModal({ if (!open) return null + const saveLabel = targetMode === 'append' ? 'An Modul anfügen' : 'Modul anlegen' + return (

Auswahl als Trainingsmodul

- Die gewählten Übungen werden in der Reihenfolge der Liste als Modulpositionen - übernommen. Pro Übung kann optional eine Variante gesetzt werden. + Die gewählten Übungen werden in der Reihenfolge der Auswahl übernommen. Pro Übung kann + optional eine Variante gesetzt werden.

{loading ? ( @@ -175,17 +217,95 @@ export default function SaveSelectedExercisesAsModuleModal({ ) : (
-
- - + +
+ {targetMode === 'append' ? ( +
+ + {modulesLoading ? ( +

Module laden …

+ ) : moduleOptions.length === 0 ? ( +

+ Keine bearbeitbaren Module gefunden. Lege zuerst ein neues Modul an. +

+ ) : ( + + )} +

+ Die neuen Übungen werden ans Ende des gewählten Moduls angefügt. +

+
+ ) : ( + <> +
+ + setTitle(e.target.value)} + required + placeholder="z. B. Technikblock Grundlagen" + /> +
+ +
+ + +
+ {visibility === 'club' ? ( +
+ + +
+ ) : null} + + )} +
- -
- - -
- {visibility === 'club' ? ( -
- - -
- ) : null}
diff --git a/frontend/src/utils/exerciseListSelection.js b/frontend/src/utils/exerciseListSelection.js new file mode 100644 index 0000000..f915f00 --- /dev/null +++ b/frontend/src/utils/exerciseListSelection.js @@ -0,0 +1,90 @@ +/** Minimaler Snapshot einer Übung für die modulübergreifende Auswahl (filterunabhängig). */ +export function snapshotExerciseForSelection(exercise) { + if (!exercise || exercise.id == null) return null + const id = Number(exercise.id) + if (!Number.isFinite(id) || id < 1) return null + return { + id, + title: exercise.title || '', + summary: exercise.summary || '', + visibility: exercise.visibility, + status: exercise.status, + exercise_kind: exercise.exercise_kind, + created_by: exercise.created_by, + focus_area: exercise.focus_area, + focus_area_names: exercise.focus_area_names, + style_direction_names: exercise.style_direction_names, + training_type_names: exercise.training_type_names, + media_count: exercise.media_count, + variant_count: exercise.variant_count, + media: Array.isArray(exercise.media) ? exercise.media : [], + } +} + +export function normalizeSelectedEntries(raw) { + if (!Array.isArray(raw)) return [] + const out = [] + const seen = new Set() + for (const item of raw) { + const snap = snapshotExerciseForSelection(item) + if (!snap || seen.has(snap.id)) continue + seen.add(snap.id) + out.push(snap) + } + return out +} + +export function mergeSelectedWithListEntries(selectedEntries, exercises) { + const byId = new Map() + for (const e of exercises || []) { + const id = Number(e?.id) + if (Number.isFinite(id) && id > 0) byId.set(id, e) + } + return (selectedEntries || []).map((entry) => byId.get(Number(entry.id)) || entry) +} + +export function moduleItemToPayload(row, orderIndex) { + if ((row?.item_type || 'exercise') === 'note') { + return { + item_type: 'note', + order_index: orderIndex, + note_body: row.note_body ?? '', + } + } + const eid = Number(row.exercise_id) + if (!Number.isFinite(eid) || eid < 1) return null + const vidRaw = row.exercise_variant_id + const vid = + vidRaw === '' || vidRaw == null || row.exercise_kind === 'combination' + ? null + : Number(vidRaw) + return { + item_type: 'exercise', + order_index: orderIndex, + exercise_id: eid, + exercise_variant_id: Number.isFinite(vid) && vid > 0 ? vid : null, + planned_duration_min: + row.planned_duration_min !== '' && row.planned_duration_min != null + ? Number(row.planned_duration_min) + : null, + notes: row.notes != null && String(row.notes).trim() ? String(row.notes).trim() : null, + } +} + +export function buildRowsPayload(rows) { + return rows + .map((row, idx) => + moduleItemToPayload( + { + ...row, + exercise_id: row.exercise_id, + exercise_variant_id: row.exercise_variant_id, + planned_duration_min: row.planned_duration_min, + notes: row.notes, + exercise_kind: row.exercise_kind, + }, + idx + ) + ) + .filter(Boolean) +} diff --git a/frontend/src/utils/exerciseListSessionState.js b/frontend/src/utils/exerciseListSessionState.js index 5822aca..0c8a8d9 100644 --- a/frontend/src/utils/exerciseListSessionState.js +++ b/frontend/src/utils/exerciseListSessionState.js @@ -1,4 +1,5 @@ import { INITIAL_EXERCISE_LIST_FILTERS, mergeExerciseListPrefsFromApi } from '../constants/exerciseListFilters' +import { normalizeSelectedEntries } from './exerciseListSelection' const STORAGE_KEY = 'shinkan.exerciseList.session.v1' @@ -11,7 +12,7 @@ function safeParse(raw) { } } -/** @returns {{ filters: object, searchInput: string, aiSearchInput: string, mineOnly: boolean } | null} */ +/** @returns {{ filters: object, searchInput: string, aiSearchInput: string, mineOnly: boolean, selectedEntries: object[] } | null} */ export function readExerciseListSessionState() { if (typeof sessionStorage === 'undefined') return null const parsed = safeParse(sessionStorage.getItem(STORAGE_KEY)) @@ -27,6 +28,7 @@ export function readExerciseListSessionState() { searchInput: typeof parsed.searchInput === 'string' ? parsed.searchInput : '', aiSearchInput: typeof parsed.aiSearchInput === 'string' ? parsed.aiSearchInput : '', mineOnly: !!parsed.mineOnly, + selectedEntries: normalizeSelectedEntries(parsed.selectedEntries), } } @@ -40,6 +42,7 @@ export function writeExerciseListSessionState(state) { searchInput: typeof state.searchInput === 'string' ? state.searchInput : '', aiSearchInput: typeof state.aiSearchInput === 'string' ? state.aiSearchInput : '', mineOnly: !!state.mineOnly, + selectedEntries: normalizeSelectedEntries(state.selectedEntries), }) ) } catch { -- 2.43.0 From 6e6270b717b53a08473d21953a1576831cf4f94c Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 20 May 2026 07:25:05 +0200 Subject: [PATCH 3/6] Enhance navigation and return context in exercise and training module components - Introduced a new `PageReturnLink` component for consistent back navigation across pages. - Updated `SaveSelectedExercisesAsModuleModal` and `SaveExercisesAsModuleModal` to utilize `navigateWithAppReturn`, preserving navigation context when redirecting after saving. - Enhanced `TrainingModuleEditPage` and `TrainingUnitEditPage` with improved return context handling, allowing users to navigate back to their previous locations seamlessly. - Added CSS styles for the new return link to improve visual consistency and user experience. --- .../docs/technical/NAV_RETURN_CONTEXT_SPEC.md | 115 ++++++++++++++ frontend/src/app.css | 15 ++ frontend/src/components/PageReturnLink.jsx | 36 +++++ .../exercises/ExercisesListPageRoot.jsx | 4 + .../SaveSelectedExercisesAsModuleModal.jsx | 7 +- .../planning/SaveExercisesAsModuleModal.jsx | 4 +- frontend/src/pages/TrainingModuleEditPage.jsx | 35 +++-- frontend/src/pages/TrainingUnitEditPage.jsx | 14 +- frontend/src/utils/navReturnContext.js | 140 ++++++++++++++++++ frontend/src/utils/navReturnContext.test.js | 85 +++++++++++ 10 files changed, 440 insertions(+), 15 deletions(-) create mode 100644 .claude/docs/technical/NAV_RETURN_CONTEXT_SPEC.md create mode 100644 frontend/src/components/PageReturnLink.jsx create mode 100644 frontend/src/utils/navReturnContext.js create mode 100644 frontend/src/utils/navReturnContext.test.js diff --git a/.claude/docs/technical/NAV_RETURN_CONTEXT_SPEC.md b/.claude/docs/technical/NAV_RETURN_CONTEXT_SPEC.md new file mode 100644 index 0000000..b196944 --- /dev/null +++ b/.claude/docs/technical/NAV_RETURN_CONTEXT_SPEC.md @@ -0,0 +1,115 @@ +# Navigation — Return-Kontext (Rücksprung) + +**Stand:** 2026-05-20 +**Status:** Spezifikation + schrittweise Umsetzung (Pilot) +**Ziel:** In der PWA (ohne Browser-Back) zuverlässig an den fachlichen Ausgangspunkt zurückkehren — inkl. sinnvollem Label und optional UI-State. + +--- + +## Problem + +Viele Flows navigieren von Kontext A zu Editor/Detail B (z. B. Übungsliste → Modulbearbeitung). Die Zielseite kennt A nicht und bietet nur einen **fest verdrahteten** Zurück-Link (z. B. immer „Modul-Bibliothek“). In der installierten PWA fehlt zusätzlich die Browser-Chrome. + +Betroffen u. a.: + +- Übungsliste → Modul anlegen/bearbeiten +- Planung → Einheiten-Editor (teilweise gelöst via `planningReturn`) +- Modals mit Speichern + Redirect auf Vollseite + +--- + +## Strategie (Hybrid) + +| Mechanismus | Wann | +|-------------|------| +| **Expliziter Return-Kontext** (`appReturn` in Router-State) | Seitenwechsel, bei denen das Ziel einen fachlichen Rücksprung anbieten soll | +| **History-Back** (`navigate(-1)`) | Fallback, wenn kein Kontext gesetzt ist und History-Eintrag existiert | +| **Default-Pfad** | Fallback der Zielseite (z. B. Modul-Bibliothek) | +| **Modal schließen** | Overlays/Peek — kein Routing-Return | + +**Nicht** als alleinige Lösung: reines Browser-Back (History durch `replace`, Deep Links, Reload unzuverlässig). + +--- + +## Datenmodell + +Router-State-Schlüssel: **`appReturn`** + +```javascript +{ + v: 1, // Schema-Version + path: '/exercises', // Ziel-URL (inkl. Query, falls nötig) + label: 'Zurück zur Übungsliste', // Anzeige im UI (vollständiger Satz) + kind: 'exerciseList', // optional: Typ für erweiterte Wiederherstellung + payload: { ... } // optional: kind-spezifische Daten +} +``` + +### `kind`-Werte (erweiterbar) + +| kind | payload | path-Ableitung | +|------|---------|----------------| +| `exerciseList` | — | `/exercises` (Filter/Auswahl via sessionStorage) | +| `planningHub` | `buildPlanningHubReturnState(...)` | `planningHubPathFromReturnState(payload)` | +| `trainingModulesList` | — | `/planning/training-modules` | +| (frei) | — | `path` direkt gesetzt | + +### Legacy-Kompatibilität + +Bestehendes Feld **`planningReturn`** (Planung ↔ Einheiten-Editor) wird beim Lesen in `appReturn` **bridged** — keine Big-Bang-Migration nötig. + +--- + +## API (Frontend) + +Zentrale Datei: `frontend/src/utils/navReturnContext.js` + +| Funktion | Zweck | +|----------|--------| +| `buildNavReturnContext({ path, label, kind?, payload? })` | Kontext-Objekt erzeugen | +| `buildExercisesListReturnContext()` | Standard-Rückkehr Übungsliste | +| `buildPlanningHubReturnContext(hubState)` | Planungs-Hub inkl. Filter-Query | +| `buildTrainingModulesListReturnContext()` | Modul-Bibliothek | +| `readNavReturnFromLocation(location)` | Kontext aus `location.state` (+ Legacy) | +| `resolveNavReturnTarget(location, fallback)` | `{ path, label }` für UI | +| `goNavReturn(navigate, location, fallback?)` | Programmatischer Rücksprung (priorisiert: Kontext → History → Fallback) | +| `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. + +--- + +## 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). +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). +6. **UI-State** (Filter, Auswahl): weiter über bestehende Session-Mechanismen (z. B. `exerciseListSessionState`), nicht im Return-Payload duplizieren, außer kind erfordert Query-Reconstruction (Planung). + +--- + +## Pilot-Umsetzung (Phase 1) + +- [x] Spec + Utility + Tests +- [x] `PageReturnLink` +- [x] Übungsliste → Modul speichern → Modul-Editor (dynamischer Zurück-Link) +- [x] Planung: `SaveExercisesAsModuleModal` leitet Return-Kontext weiter +- [x] `TrainingUnitEditPage`: `goBack` über `goNavReturn` (Legacy-bridge) + +## Folge-Phasen (noch offen) + +- Weitere Editoren (Übung, Vorlage, Rahmenprogramm) +- Optional: globaler Zurück-Button in App-Chrome (Mobile) +- Nach Speichern: explizite Aktion „Zurück zum Ausgang“ im Toast +- `ExercisePeekModal` → Vollseite mit Return + +--- + +## Referenzen + +- Bestehend: `frontend/src/utils/planningUnitRoutes.js` (`planningReturn`) +- Session Übungsliste: `frontend/src/utils/exerciseListSessionState.js` +- PWA-Kontext: `docs/FACHLICHE_NUTZERFUNKTIONEN.md`, App-Shell in `App.jsx` diff --git a/frontend/src/app.css b/frontend/src/app.css index 65d308e..48bb744 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1459,6 +1459,21 @@ html.modal-scroll-locked .app-main { .page-form-editor__intro { min-width: 0; } +.page-return-link { + display: inline-block; + margin-bottom: 0.75rem; + color: var(--accent-dark); + font-weight: 600; + text-decoration: none; +} +.page-return-link:hover { + text-decoration: underline; +} +@media (prefers-color-scheme: dark) { + .page-return-link { + color: var(--accent); + } +} .page-form-editor__back { display: inline-block; margin-bottom: 0.35rem; diff --git a/frontend/src/components/PageReturnLink.jsx b/frontend/src/components/PageReturnLink.jsx new file mode 100644 index 0000000..87d4975 --- /dev/null +++ b/frontend/src/components/PageReturnLink.jsx @@ -0,0 +1,36 @@ +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/ExercisesListPageRoot.jsx b/frontend/src/components/exercises/ExercisesListPageRoot.jsx index 5bdfc84..4b4e2c6 100644 --- a/frontend/src/components/exercises/ExercisesListPageRoot.jsx +++ b/frontend/src/components/exercises/ExercisesListPageRoot.jsx @@ -11,6 +11,7 @@ import ExerciseListSearchBar from './ExerciseListSearchBar' import ExerciseListBulkToolbar from './ExerciseListBulkToolbar' import SaveSelectedExercisesAsModuleModal from './SaveSelectedExercisesAsModuleModal' import ExercisePeekModal from '../ExercisePeekModal' +import { buildExercisesListReturnContext } from '../../utils/navReturnContext' import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips' import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../../utils/exerciseListQuery' import { readExerciseListSessionState, writeExerciseListSessionState } from '../../utils/exerciseListSessionState' @@ -296,6 +297,8 @@ function ExercisesListPageRoot() { const selectedExercisesInListOrder = selectedExercisesDisplay + const exercisesModuleReturnContext = useMemo(() => buildExercisesListReturnContext(), []) + const bulkVisibilityOptions = useMemo(() => { const base = [ { id: '', label: '— nicht ändern —' }, @@ -595,6 +598,7 @@ function ExercisesListPageRoot() { open={saveModuleModalOpen} onClose={() => setSaveModuleModalOpen(false)} selectedExercises={selectedExercisesInListOrder} + returnContext={exercisesModuleReturnContext} onSuccess={clearSelection} /> diff --git a/frontend/src/components/exercises/SaveSelectedExercisesAsModuleModal.jsx b/frontend/src/components/exercises/SaveSelectedExercisesAsModuleModal.jsx index 15dc269..0579f89 100644 --- a/frontend/src/components/exercises/SaveSelectedExercisesAsModuleModal.jsx +++ b/frontend/src/components/exercises/SaveSelectedExercisesAsModuleModal.jsx @@ -8,6 +8,7 @@ import { useAuth } from '../../context/AuthContext' import { activeClubMemberships, getDefaultClubIdForGovernanceForms } from '../../utils/activeClub' import { hydrateExercisePlanningRow } from '../../utils/trainingUnitSectionsForm' import { buildRowsPayload, moduleItemToPayload } from '../../utils/exerciseListSelection' +import { navigateWithAppReturn } from '../../utils/navReturnContext' /** * Erstellt ein Trainingsmodul aus per Checkbox ausgewählten Übungen (Reihenfolge = Auswahlreihenfolge). @@ -18,6 +19,8 @@ export default function SaveSelectedExercisesAsModuleModal({ onClose, /** @type {Array<{ id: number, title?: string }>} */ selectedExercises, + /** Return-Kontext für Modul-Editor (z. B. Übungsliste) */ + returnContext, onSuccess, }) { const navigate = useNavigate() @@ -153,7 +156,7 @@ export default function SaveSelectedExercisesAsModuleModal({ ] await api.updateTrainingModule(mid, { items: merged }) toast.success(`${newItemsPayload.length} Übung(en) an Modul angefügt.`) - navigate(`/planning/training-modules/${mid}`) + navigateWithAppReturn(navigate, `/planning/training-modules/${mid}`, returnContext) onSuccess?.() onClose() return @@ -184,7 +187,7 @@ export default function SaveSelectedExercisesAsModuleModal({ }) toast.success('Trainingsmodul gespeichert.') if (created?.id) { - navigate(`/planning/training-modules/${created.id}`) + navigateWithAppReturn(navigate, `/planning/training-modules/${created.id}`, returnContext) } onSuccess?.() onClose() diff --git a/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx b/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx index ee2c119..3c49298 100644 --- a/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx +++ b/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx @@ -7,6 +7,7 @@ import { useToast } from '../../context/ToastContext' import { useAuth } from '../../context/AuthContext' import { activeClubMemberships } from '../../utils/activeClub' import { collectExercisePlacementsForModule } from '../../utils/trainingPlanModuleFromUnit' +import { navigateWithAppReturn } from '../../utils/navReturnContext' /** * Erstellt ein Trainingsmodul aus den Übungen einer gespeicherten Trainingseinheit (Mehrfachauswahl). @@ -16,6 +17,7 @@ export default function SaveExercisesAsModuleModal({ onClose, unitId, planningModalClubId, + returnContext, onSuccess, }) { const navigate = useNavigate() @@ -134,7 +136,7 @@ export default function SaveExercisesAsModuleModal({ }) toast.success('Trainingsmodul gespeichert.') if (created?.id) { - navigate(`/planning/training-modules/${created.id}`) + navigateWithAppReturn(navigate, `/planning/training-modules/${created.id}`, returnContext) } onSuccess?.() onClose() diff --git a/frontend/src/pages/TrainingModuleEditPage.jsx b/frontend/src/pages/TrainingModuleEditPage.jsx index 064897d..ea416d6 100644 --- a/frontend/src/pages/TrainingModuleEditPage.jsx +++ b/frontend/src/pages/TrainingModuleEditPage.jsx @@ -1,14 +1,21 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Link, useNavigate, useParams } from 'react-router-dom' +import { useLocation, useNavigate, useParams } from 'react-router-dom' import api from '../utils/api' import ExercisePickerModal from '../components/ExercisePickerModal' import FormActionBar from '../components/FormActionBar' +import PageReturnLink from '../components/PageReturnLink' import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm' import { useAuth } from '../context/AuthContext' import { useToast } from '../context/ToastContext' import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt' import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker' import { activeClubMemberships, getDefaultClubIdForGovernanceForms, getTenantClubDependencyKey } from '../utils/activeClub' +import { + TRAINING_MODULES_LIST_PATH, + buildTrainingModulesListReturnContext, + goNavReturn, + preserveAppReturnOnNavigate, +} from '../utils/navReturnContext' function moduleFormSnapshot({ title, @@ -62,6 +69,7 @@ function swapItems(arr, i, j) { export default function TrainingModuleEditPage() { const { id: routeId } = useParams() const navigate = useNavigate() + const location = useLocation() const isNew = !routeId || routeId === 'new' const moduleId = !isNew ? parseInt(routeId, 10) : NaN @@ -302,6 +310,12 @@ export default function TrainingModuleEditPage() { } } + const moduleListReturn = useMemo(() => buildTrainingModulesListReturnContext(), []) + + const goBack = useCallback(() => { + goNavReturn(navigate, location, moduleListReturn) + }, [navigate, location, moduleListReturn]) + const performModuleSave = async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => { if (!title.trim()) { toast.error('Titel ist Pflicht.') @@ -315,9 +329,11 @@ export default function TrainingModuleEditPage() { const created = await api.createTrainingModule(body) toast.success('Trainingsmodul angelegt.') if (closeAfter) { - navigate('/planning/training-modules') + goBack() } else if (!fromUnsavedDialog) { - navigate(`/planning/training-modules/${created.id}`, { replace: true }) + preserveAppReturnOnNavigate(navigate, location, `/planning/training-modules/${created.id}`, { + replace: true, + }) } return true } @@ -325,7 +341,7 @@ export default function TrainingModuleEditPage() { baselineRef.current = moduleFormSnapshot(latestFormRef.current) setBypassDirty(false) toast.success('Gespeichert.') - if (closeAfter) navigate('/planning/training-modules') + if (closeAfter) goBack() return true } catch (err) { const msg = err.message || 'Speichern fehlgeschlagen' @@ -361,11 +377,10 @@ export default function TrainingModuleEditPage() { return (
-

- - ← Zurück zur Modul‑Bibliothek - -

+

{isNew ? 'Neues Trainingsmodul' : 'Trainingsmodul bearbeiten'}

Reihenfolge der Positionen entspricht der späteren Übernahme in einen Abschnitt der Einheit (Kopie). @@ -670,7 +685,7 @@ export default function TrainingModuleEditPage() { saving={saving} onSave={() => handleSave()} onSaveAndClose={handleSaveAndClose} - onCancel={() => navigate('/planning/training-modules')} + onCancel={goBack} cancelLabel="Abbrechen" /> diff --git a/frontend/src/pages/TrainingUnitEditPage.jsx b/frontend/src/pages/TrainingUnitEditPage.jsx index 45b0745..ae5841e 100644 --- a/frontend/src/pages/TrainingUnitEditPage.jsx +++ b/frontend/src/pages/TrainingUnitEditPage.jsx @@ -33,6 +33,7 @@ import PageFormEditorChrome from '../components/PageFormEditorChrome' import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt' import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker' import { buildPlanUnitEditPath, planningHubPathFromReturnState } from '../utils/planningUnitRoutes' +import { goNavReturn, buildCurrentLocationReturnContext } from '../utils/navReturnContext' export default function TrainingUnitEditPage() { const { id: routeId } = useParams() @@ -119,8 +120,16 @@ export default function TrainingUnitEditPage() { const [saveModuleOpen, setSaveModuleOpen] = useState(false) const goBack = useCallback(() => { - navigate(planningHubPathFromReturnState(location.state?.planningReturn)) - }, [location.state, navigate]) + goNavReturn(navigate, location, { + path: planningHubPathFromReturnState(location.state?.planningReturn), + label: 'Zurück zur Planung', + }) + }, [location, navigate]) + + const moduleSaveReturnContext = useMemo( + () => buildCurrentLocationReturnContext(location, 'Zurück zur Trainingseinheit'), + [location] + ) const planningClubId = useMemo(() => { const gid = Number(formData.group_id) @@ -731,6 +740,7 @@ export default function TrainingUnitEditPage() { onSuccess={() => setSaveModuleOpen(false)} unitId={editingUnit?.id} planningModalClubId={planningClubId} + returnContext={moduleSaveReturnContext} /> |object} hubState */ +export function buildPlanningHubReturnContext(hubState = {}) { + const payload = buildPlanningHubReturnState(hubState) + return buildNavReturnContext({ + path: planningHubPathFromReturnState(payload), + label: 'Zurück zur Planung', + kind: 'planningHub', + payload, + }) +} + +/** Legacy planningReturn → appReturn */ +export function appReturnFromPlanningReturn(planningReturn) { + if (!planningReturn || planningReturn.v !== 1) return null + return buildPlanningHubReturnContext(planningReturn) +} + +/** + * @param {import('react-router-dom').Location|{ state?: object }|null|undefined} location + */ +export function readNavReturnFromLocation(location) { + const state = location?.state + if (!state || typeof state !== 'object') return null + const raw = state[NAV_RETURN_STATE_KEY] + if (raw?.v === 1 && raw.path && raw.label) return raw + if (state.planningReturn) return appReturnFromPlanningReturn(state.planningReturn) + return null +} + +/** + * @param {import('react-router-dom').Location|{ state?: object }|null|undefined} location + * @param {{ path: string, label: string }|null|undefined} fallback + */ +export function resolveNavReturnTarget(location, fallback) { + const ctx = readNavReturnFromLocation(location) + if (ctx?.path && ctx?.label) { + return { path: ctx.path, label: ctx.label, fromContext: true } + } + if (fallback?.path && fallback?.label) { + return { path: fallback.path, label: fallback.label, fromContext: false } + } + return null +} + +/** + * @param {import('react-router-dom').NavigateFunction} navigate + * @param {import('react-router-dom').Location|{ state?: object }|null|undefined} location + * @param {{ path?: string, label?: string }|null|undefined} fallback + */ +export function goNavReturn(navigate, location, fallback) { + const target = resolveNavReturnTarget(location, fallback) + if (target?.fromContext && target.path) { + navigate(target.path) + return + } + if (typeof window !== 'undefined' && window.history.length > 1) { + navigate(-1) + return + } + if (fallback?.path) { + navigate(fallback.path) + return + } + navigate('/') +} + +/** + * @param {import('react-router-dom').NavigateFunction} navigate + * @param {string} to + * @param {ReturnType|null|undefined} returnContext + * @param {object} [options] + */ +export function navigateWithAppReturn(navigate, to, returnContext, options = {}) { + const state = { ...(options.state || {}) } + if (returnContext) state[NAV_RETURN_STATE_KEY] = returnContext + navigate(to, { ...options, state }) +} + +/** + * Bestehenden appReturn (oder Legacy planningReturn) beim Weiterleiten erhalten. + */ +export function preserveAppReturnOnNavigate(navigate, location, to, options = {}) { + const existing = readNavReturnFromLocation(location) + const state = { ...(options.state || {}) } + if (existing) state[NAV_RETURN_STATE_KEY] = existing + navigate(to, { ...options, state }) +} diff --git a/frontend/src/utils/navReturnContext.test.js b/frontend/src/utils/navReturnContext.test.js new file mode 100644 index 0000000..a9dc92f --- /dev/null +++ b/frontend/src/utils/navReturnContext.test.js @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from 'vitest' +import { + appReturnFromPlanningReturn, + buildExercisesListReturnContext, + buildNavReturnContext, + buildPlanningHubReturnContext, + goNavReturn, + readNavReturnFromLocation, + resolveNavReturnTarget, +} from './navReturnContext.js' + +describe('navReturnContext', () => { + it('buildNavReturnContext requires path and label', () => { + expect(buildNavReturnContext({ path: '/x', label: 'Zurück' })).toEqual({ + v: 1, + path: '/x', + label: 'Zurück', + }) + expect(buildNavReturnContext({ path: '', label: 'X' })).toBeNull() + }) + + it('buildExercisesListReturnContext', () => { + const ctx = buildExercisesListReturnContext() + expect(ctx.path).toBe('/exercises') + expect(ctx.kind).toBe('exerciseList') + }) + + it('readNavReturnFromLocation prefers appReturn', () => { + const ctx = buildExercisesListReturnContext() + expect(readNavReturnFromLocation({ state: { appReturn: ctx } })).toEqual(ctx) + }) + + it('readNavReturnFromLocation bridges planningReturn', () => { + const loc = { + state: { + planningReturn: { + v: 1, + selectedGroupId: '3', + planView: 'list', + calendarMonthStr: '', + startDate: '', + endDate: '', + planScope: 'group', + assignedToMeOnly: false, + }, + }, + } + const ctx = readNavReturnFromLocation(loc) + expect(ctx?.kind).toBe('planningHub') + expect(ctx?.path).toContain('/planning') + expect(ctx?.label).toBe('Zurück zur Planung') + }) + + it('appReturnFromPlanningReturn', () => { + const ctx = appReturnFromPlanningReturn({ + v: 1, + selectedGroupId: '', + planView: 'calendar', + calendarMonthStr: '2026-05', + startDate: '', + endDate: '', + planScope: 'group', + assignedToMeOnly: false, + }) + expect(ctx?.path).toContain('month=2026-05') + }) + + it('resolveNavReturnTarget uses fallback when no state', () => { + const fb = { path: '/planning/training-modules', label: 'Zurück zur Modul-Bibliothek' } + expect(resolveNavReturnTarget({ state: null }, fb)).toEqual({ ...fb, fromContext: false }) + }) + + it('goNavReturn navigates to context path first', () => { + const navigate = vi.fn() + const ctx = buildExercisesListReturnContext() + goNavReturn(navigate, { state: { appReturn: ctx } }, null) + expect(navigate).toHaveBeenCalledWith('/exercises') + }) + + it('buildPlanningHubReturnContext', () => { + const ctx = buildPlanningHubReturnContext({ selectedGroupId: '7', planView: 'list' }) + expect(ctx?.path).toContain('group=7') + expect(ctx?.kind).toBe('planningHub') + }) +}) -- 2.43.0 From 4588ef4c7eb6e02d75f261bf6e9d024dd6ef2059 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 20 May 2026 07:42:46 +0200 Subject: [PATCH 4/6] 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() {
    {trainingHome.upcoming.map((u) => (
  • - + {unitWhenLabel(u)} - + {u.group_name ? ( {` — ${u.group_name}`} ) : null} @@ -237,9 +253,13 @@ function Dashboard() { const snippet = (u.trainer_notes || u.notes || '').trim().slice(0, 120) return (
  • - + {unitWhenLabel(u)} - + {u.group_name ? ( {` · ${u.group_name}`} ) : null} @@ -266,9 +286,13 @@ function Dashboard() {
      {trainingHome.reviewPending.map((u) => (
    • - + {(u.actual_date || u.planned_date || '').toString().slice(0, 10) || 'Datum'} - + {u.group_name ? ( {` — ${u.group_name}`} ) : null} diff --git a/frontend/src/pages/ExerciseDetailPage.jsx b/frontend/src/pages/ExerciseDetailPage.jsx index 584a347..97c0fa9 100644 --- a/frontend/src/pages/ExerciseDetailPage.jsx +++ b/frontend/src/pages/ExerciseDetailPage.jsx @@ -1,6 +1,12 @@ -import React, { useEffect, useState } from 'react' -import { Link, useNavigate, useParams, useLocation } from 'react-router-dom' +import React, { useEffect, useMemo, useState } from 'react' +import { useParams, useLocation } from 'react-router-dom' import api from '../utils/api' +import PageReturnButton from '../components/PageReturnButton' +import NavStateLink from '../components/NavStateLink' +import { + EXERCISES_LIST_PATH, + buildCurrentLocationReturnContext, +} from '../utils/navReturnContext' import ExerciseRichTextBlock from '../components/ExerciseRichTextBlock' import ExerciseAttachmentMediaStrip from '../components/ExerciseAttachmentMediaStrip' import CombinationPlanBracket from '../components/CombinationPlanBracket' @@ -54,8 +60,11 @@ function metaParts(exercise) { function ExerciseDetailPage() { const { id } = useParams() - const navigate = useNavigate() const location = useLocation() + const editReturnContext = useMemo( + () => buildCurrentLocationReturnContext(location, 'Zurück zur Übung'), + [location] + ) const [exercise, setExercise] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) @@ -98,9 +107,10 @@ function ExerciseDetailPage() {

      Übung

      {msg}

      - +
      ) @@ -109,7 +119,6 @@ function ExerciseDetailPage() { if (!exercise) return null const meta = metaParts(exercise) - const fromExerciseEdit = location.state?.fromExerciseEdit === true const isCombinationDetail = (exercise.exercise_kind || '').toLowerCase().trim() === 'combination' && @@ -131,13 +140,19 @@ function ExerciseDetailPage() { onClose={() => setEmbeddedPeekExerciseId(null)} />
      - +
      - - {fromExerciseEdit ? 'Zurück zur Bearbeitung' : 'Bearbeiten'} - + + Bearbeiten +
      diff --git a/frontend/src/pages/MediaLibraryPage.jsx b/frontend/src/pages/MediaLibraryPage.jsx index 059d620..abaf85e 100644 --- a/frontend/src/pages/MediaLibraryPage.jsx +++ b/frontend/src/pages/MediaLibraryPage.jsx @@ -1,5 +1,7 @@ import { useEffect, useState, useCallback, useRef, useMemo } from 'react' import { Link } from 'react-router-dom' +import NavStateLink from '../components/NavStateLink' +import { buildMediaLibraryReturnContext } from '../utils/navReturnContext' import { LayoutGrid, List, @@ -108,21 +110,23 @@ function parseTagsInput(s) { .filter(Boolean) } -function MediaUsageBlock({ usage, compact }) { +function MediaUsageBlock({ usage, compact, returnContext }) { const u = usage || { exercises: [], training_units: [] } const ex = u.exercises || [] const tus = u.training_units || [] if (!ex.length && !tus.length) return {compact ? '—' : 'Noch in keiner Übung / Einheit verknüpft.'} + const LinkOrNav = returnContext ? NavStateLink : Link + const linkExtra = returnContext ? { returnContext } : {} return (
      {ex.length ? (
      Übungen{' '} {ex.map((e) => ( - + {e.title.length > (compact ? 18 : 40) ? `${e.title.slice(0, compact ? 18 : 40)}…` : e.title} - + ))}
      ) : null} @@ -134,9 +138,9 @@ function MediaUsageBlock({ usage, compact }) { [t.planned_date, (t.group_name || '').trim()].filter(Boolean).join(' · ') || `Einheit #${t.id}` const short = label.length > (compact ? 20 : 36) ? `${label.slice(0, compact ? 20 : 36)}…` : label return ( - + {short} - + ) })}
      @@ -297,6 +301,7 @@ export default function MediaLibraryPage() { const isSuperadmin = user?.role === 'superadmin' const hasClubOrgAdmin = activeClubMemberships(user?.clubs).some((c) => (c.roles || []).includes('club_admin')) const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) + const mediaLibraryReturn = useMemo(() => buildMediaLibraryReturnContext(), []) const archiveVisOptions = useMemo( () => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin), @@ -1004,7 +1009,7 @@ export default function MediaLibraryPage() { ))} ) : null} - + {(it.lifecycle_state || 'active') === 'active' && !it.legal_hold_active && ( + ) } @@ -680,6 +689,7 @@ export default function TrainingCoachPage() { key={candidatePeekId != null ? String(candidatePeekId) : 'coach-peek-closed'} open={candidatePeekId != null} exerciseId={candidatePeekId} + returnContext={runReturn} onClose={() => setCandidatePeekId(null)} />