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