From d19a1061d83d98c36ea5b17ebf929413636816e0 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 20 May 2026 06:37:40 +0200 Subject: [PATCH 1/2] Enhance exercise listing with variant and media counts - Updated the `list_exercises` function to include counts for exercise variants and media, improving data retrieval for exercise details. - Added new CSS styles for the exercise card footer to display variant and media statistics in a visually appealing manner. - Implemented `ExerciseCardContentStats` component to conditionally render variant and media counts, enhancing the user interface of exercise cards. --- backend/routers/exercises.py | 14 ++++- frontend/src/app.css | 28 ++++++++++ .../components/exercises/ExerciseListCard.jsx | 53 ++++++++++++++++++- 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 02ffcc6..8fd392e 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -2095,7 +2095,17 @@ def list_exercises( FROM exercise_training_types ett JOIN training_types tt ON tt.id = ett.training_type_id WHERE ett.exercise_id = e.id - ) AS training_type_names + ) AS training_type_names, + ( + SELECT COUNT(*)::int + FROM exercise_variants ev + WHERE ev.exercise_id = e.id + ) AS variant_count, + ( + SELECT COUNT(*)::int + FROM exercise_media em + WHERE em.exercise_id = e.id + ) AS media_count {variants_sql} FROM exercises e LEFT JOIN profiles p ON e.created_by = p.id @@ -2118,6 +2128,8 @@ def list_exercises( d["focus_area_names"] = _coerce_json_str_list(d.get("focus_area_names")) d["style_direction_names"] = _coerce_json_str_list(d.get("style_direction_names")) d["training_type_names"] = _coerce_json_str_list(d.get("training_type_names")) + d["variant_count"] = int(d.get("variant_count") or 0) + d["media_count"] = int(d.get("media_count") or 0) if include_variants: v = d.get("variants") if isinstance(v, str): diff --git a/frontend/src/app.css b/frontend/src/app.css index 0c500ce..f5bcf83 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -4819,6 +4819,34 @@ html.modal-scroll-locked .app-main { min-height: 44px; box-sizing: border-box; } +.exercise-card__footer-meta { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + min-width: 0; + flex-shrink: 1; +} +.exercise-card__content-stats { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--text3); +} +.exercise-card__stat { + display: inline-flex; + align-items: center; + gap: 3px; + color: var(--text3); + font-size: 12px; + line-height: 1; +} +.exercise-card__stat-num { + font-variant-numeric: tabular-nums; + font-weight: 600; + font-size: 12px; + line-height: 1; +} .exercise-card__meta-compact { display: inline-flex; align-items: center; diff --git a/frontend/src/components/exercises/ExerciseListCard.jsx b/frontend/src/components/exercises/ExerciseListCard.jsx index 1ef58e8..234c69d 100644 --- a/frontend/src/components/exercises/ExerciseListCard.jsx +++ b/frontend/src/components/exercises/ExerciseListCard.jsx @@ -11,6 +11,8 @@ import { Archive, CircleDot, FilePenLine, + Image, + Layers, } from 'lucide-react' import ExerciseRichTextBlock from '../ExerciseRichTextBlock' import { coerceApiNameList } from '../../utils/sanitizeHtml' @@ -48,6 +50,52 @@ function exerciseCardClassName(exercise, userId) { .join(' ') } +function exerciseVariantCount(exercise) { + if (typeof exercise?.variant_count === 'number' && Number.isFinite(exercise.variant_count)) { + return Math.max(0, exercise.variant_count) + } + return Array.isArray(exercise?.variants) ? exercise.variants.length : 0 +} + +function exerciseMediaCount(exercise) { + if (typeof exercise?.media_count === 'number' && Number.isFinite(exercise.media_count)) { + return Math.max(0, exercise.media_count) + } + return Array.isArray(exercise?.media) ? exercise.media.length : 0 +} + +function ExerciseCardContentStats({ exercise }) { + const mediaCount = exerciseMediaCount(exercise) + const variantCount = exerciseVariantCount(exercise) + if (mediaCount <= 0 && variantCount <= 0) return null + + const mediaLabel = + mediaCount === 1 ? '1 Medium hinterlegt' : `${mediaCount} Medien hinterlegt` + const variantLabel = + variantCount === 1 ? '1 Variante' : `${variantCount} Varianten` + + return ( +
+ {mediaCount > 0 ? ( + + + + {mediaCount} + + + ) : null} + {variantCount > 0 ? ( + + + + {variantCount} + + + ) : null} +
+ ) +} + function ExerciseCardScopeStatus({ exercise }) { const v = exercise.visibility || 'private' const s = exercise.status || 'draft' @@ -138,7 +186,10 @@ export default function ExerciseListCard({ exercise, user, selectedIds, toggleSe
- +
+ + +
Date: Wed, 20 May 2026 06:38:53 +0200 Subject: [PATCH 2/2] Enhance ExerciseFormPageRoot with save and close functionality - Added a new `handleSaveAndClose` function to allow users to save and navigate back to the exercise list. - Updated `performSaveAttempt` to accept a `closeAfter` parameter for conditional navigation. - Refactored form submission handling to include separate actions for saving and saving with closure. - Integrated `PageFormEditorChrome` for improved layout and user experience, including a back navigation option. --- .../exercises/ExerciseFormPageRoot.jsx | 87 ++++++++++++------- 1 file changed, 58 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx index 5d4a94a..2e1aa9d 100644 --- a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx +++ b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx @@ -26,6 +26,7 @@ import { COMBINATION_ARCHETYPE_OPTIONS, ARCHETYPE_DEFAULT_REP_SERIES_COUNT, defa import { readSlotProfilesV1, normalizeAdvanceMode, parseComboRepSeriesCountUi } from '../../utils/combinationMethodProfileUi' import { GripVertical } from 'lucide-react' import UnsavedChangesPrompt from '../UnsavedChangesPrompt' +import PageFormEditorChrome from '../PageFormEditorChrome' import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../../hooks/useUnsavedChangesBlocker' const INTENSITY_OPTIONS = [ @@ -836,7 +837,7 @@ function ExerciseFormPageRoot() { } const performSaveAttempt = useCallback( - async ({ fromUnsavedDialog = false } = {}) => { + async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => { if (!formData.title || formData.title.trim().length < 3) { toast.error('Titel mindestens 3 Zeichen') return false @@ -940,12 +941,15 @@ function ExerciseFormPageRoot() { setVariants((ex.variants || []).map(apiVariantToRow)) setFormDirty(false) toast.success('Gespeichert.') + if (closeAfter) navigate('/exercises') return true } const created = await api.createExercise(payload) setFormDirty(false) toast.success('Übung angelegt.') - if (!fromUnsavedDialog) { + if (closeAfter) { + navigate('/exercises') + } else if (!fromUnsavedDialog) { navigate(`/exercises/${created.id}/edit`, { replace: true }) } return true @@ -959,10 +963,39 @@ function ExerciseFormPageRoot() { [exerciseId, formData, isEdit, navigate, toast], ) - const handleSubmit = async (e) => { - e.preventDefault() - await performSaveAttempt({ fromUnsavedDialog: false }) - } + const handleSubmit = useCallback( + async (e) => { + e?.preventDefault?.() + await performSaveAttempt({ fromUnsavedDialog: false, closeAfter: false }) + }, + [performSaveAttempt], + ) + + const handleSaveAndClose = useCallback( + async (e) => { + e?.preventDefault?.() + await performSaveAttempt({ fromUnsavedDialog: false, closeAfter: true }) + }, + [performSaveAttempt], + ) + + const goBackToList = useCallback(() => { + navigate('/exercises') + }, [navigate]) + + const actionConfig = useMemo( + () => ({ + formId: 'exercise-form', + saving, + isNew: !isEdit, + onSave: handleSubmit, + onSaveAndClose: handleSaveAndClose, + onCancel: goBackToList, + showSave: true, + showSaveAndClose: true, + }), + [saving, isEdit, handleSubmit, handleSaveAndClose, goBackToList], + ) const handleUnsavedDialogSave = async () => { const ok = await performSaveAttempt({ fromUnsavedDialog: true }) @@ -1162,27 +1195,28 @@ function ExerciseFormPageRoot() { } return ( -
-
- - {isEdit && ( - - )} -
+ +

+ ) : null}
-

{isEdit ? 'Übung bearbeiten' : 'Neue Übung'}

- -
+
) : null} - -
- -
@@ -2439,8 +2467,9 @@ function ExerciseFormPageRoot() { isBusy={saving} onSave={handleUnsavedDialogSave} onDiscardWithoutSave={() => setFormDirty(false)} + detail="Du hast ungespeicherte Änderungen vorgenommen. Möchtest du die Seite wirklich verlassen?" /> -
+ ) } -- 2.43.0