From 14b005e9b801a2fa1722d7454e23c95effb27f74 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 20 May 2026 07:03:15 +0200 Subject: [PATCH] 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 {