Editor Verbesserung für Übungen #39

Merged
Lars merged 2 commits from develop into main 2026-05-20 06:42:07 +02:00
4 changed files with 151 additions and 31 deletions

View File

@ -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):

View File

@ -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;

View File

@ -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 (
<div style={{ padding: '12px' }} className="app-page">
<div style={{ marginBottom: '12px' }}>
<button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}>
Übersicht
</button>
{isEdit && (
<button
type="button"
<PageFormEditorChrome
testId="exercise-form-page"
title={isEdit ? 'Übung bearbeiten' : 'Neue Übung'}
backTo="/exercises"
backLabel="Übersicht"
actionConfig={actionConfig}
>
{isEdit ? (
<p style={{ margin: '0 0 12px' }}>
<Link
to={`/exercises/${exerciseId}`}
state={{ fromExerciseEdit: true }}
className="btn btn-secondary"
style={{ marginLeft: '8px' }}
onClick={() => navigate(`/exercises/${exerciseId}`, { state: { fromExerciseEdit: true } })}
style={{ fontSize: '0.875rem' }}
>
Ansehen
</button>
)}
</div>
</Link>
</p>
) : null}
<div className="card">
<h1 style={{ marginTop: 0, fontSize: '1.25rem' }}>{isEdit ? 'Übung bearbeiten' : 'Neue Übung'}</h1>
<form onSubmit={handleSubmit}>
<form id="exercise-form" onSubmit={handleSubmit}>
<div className="form-row">
<label className="form-label">Titel *</label>
<input
@ -1943,12 +1977,6 @@ function ExerciseFormPageRoot() {
</p>
</div>
) : null}
<div style={{ marginTop: '16px' }}>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Speichern…' : isEdit ? 'Speichern' : 'Anlegen & weiter'}
</button>
</div>
</form>
</div>
@ -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?"
/>
</div>
</PageFormEditorChrome>
)
}

View File

@ -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 (
<div className="exercise-card__content-stats">
{mediaCount > 0 ? (
<span className="exercise-card__stat" title={mediaLabel} aria-label={mediaLabel}>
<Image size={15} strokeWidth={2} aria-hidden />
<span className="exercise-card__stat-num" aria-hidden>
{mediaCount}
</span>
</span>
) : null}
{variantCount > 0 ? (
<span className="exercise-card__stat" title={variantLabel} aria-label={variantLabel}>
<Layers size={15} strokeWidth={2} aria-hidden />
<span className="exercise-card__stat-num" aria-hidden>
{variantCount}
</span>
</span>
) : null}
</div>
)
}
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
</div>
</div>
<div className="exercise-card__footer">
<ExerciseCardScopeStatus exercise={exercise} />
<div className="exercise-card__footer-meta">
<ExerciseCardScopeStatus exercise={exercise} />
<ExerciseCardContentStats exercise={exercise} />
</div>
<div className="exercise-card__actions exercise-card__actions--icons">
<Link
to={`/exercises/${exercise.id}`}