Implement Exercise Form Enhancements with New Meta Panel
All checks were successful
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s

- Added a new meta panel for exercise classification and target groups, improving the organization of exercise attributes.
- Introduced ExerciseCatalogAssocEditor component to manage focus areas, training styles, and target groups, enhancing user interaction.
- Refactored CSS styles for the new meta panel and associated components, ensuring a cohesive design and improved responsiveness.
- Removed the MultiAssocBlock component to streamline the code and improve maintainability.
This commit is contained in:
Lars 2026-05-21 14:40:50 +02:00
parent 5d308b20ba
commit 9b3f594007
4 changed files with 548 additions and 175 deletions

View File

@ -6548,6 +6548,266 @@ html.modal-scroll-locked .app-main {
min-width: 0;
}
/* Übungsformular — Klassifikation & Meta-Chips */
.exercise-form-meta-panel {
margin: 4px 0 16px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface2);
}
.exercise-form-meta-panel__title {
margin: 0 0 12px;
font-size: 1rem;
font-weight: 700;
}
.exercise-form-meta-panel__grid {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
@media (min-width: 720px) {
.exercise-form-meta-panel__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.exercise-meta-block {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
}
.exercise-meta-block--skills {
margin-top: 10px;
}
.exercise-meta-block__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.exercise-meta-block__title {
margin: 0;
font-size: 13px;
font-weight: 700;
color: var(--text1);
}
.exercise-meta-block__add {
font-size: 11px;
padding: 3px 8px;
flex-shrink: 0;
}
.exercise-meta-block__hint,
.exercise-meta-block__empty {
margin: 0 0 8px;
font-size: 12px;
color: var(--text3);
line-height: 1.35;
}
.exercise-meta-block__empty {
margin-bottom: 0;
}
.exercise-meta-block__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.exercise-catalog-chip {
display: inline-flex;
align-items: center;
gap: 2px;
max-width: 100%;
padding: 2px 4px 2px 2px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface2);
}
.exercise-catalog-chip__select {
border: none;
background: transparent;
padding: 4px 8px;
font-size: 12px;
font-family: inherit;
color: var(--text1);
min-width: 0;
max-width: min(220px, 72vw);
cursor: pointer;
}
.exercise-catalog-chip__select:focus {
outline: none;
}
.exercise-catalog-chip__primary,
.exercise-catalog-chip__remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: 999px;
background: transparent;
color: var(--text3);
font-size: 12px;
line-height: 1;
cursor: pointer;
flex-shrink: 0;
}
.exercise-catalog-chip__primary--on {
color: var(--accent-dark);
}
.exercise-catalog-chip__remove:hover,
.exercise-catalog-chip__primary:hover {
background: color-mix(in srgb, var(--border) 40%, transparent);
color: var(--text1);
}
.exercise-skills-add {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: stretch;
margin-bottom: 10px;
}
.exercise-skills-add .skill-tree-select {
flex: 1 1 180px;
min-width: 0;
}
.exercise-skills-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.exercise-skill-chip {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface2);
}
@media (min-width: 640px) {
.exercise-skill-chip {
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 12px;
}
}
.exercise-skill-chip__identity {
flex: 0 1 auto;
min-width: 0;
max-width: min(240px, 100%);
}
.exercise-skill-chip__name {
display: block;
font-size: 13px;
font-weight: 700;
color: var(--text1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.exercise-skill-chip__path {
display: block;
margin-top: 2px;
font-size: 11px;
color: var(--text3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.exercise-skill-chip__controls {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 8px 10px;
flex: 1 1 auto;
min-width: 0;
}
.exercise-skill-chip__field {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.exercise-skill-chip__caption {
font-size: 10px;
font-weight: 600;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.exercise-skill-chip__levels {
display: flex;
flex-wrap: nowrap;
align-items: flex-end;
gap: 6px;
}
.exercise-skill-level-select {
width: 52px;
min-width: 52px;
padding: 5px 6px;
font-size: 13px;
text-align: center;
}
.exercise-skill-chip__dash {
padding-bottom: 7px;
color: var(--text3);
font-size: 12px;
font-weight: 600;
}
.exercise-intensity-segment {
display: inline-flex;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: var(--surface);
}
.exercise-intensity-segment__btn {
padding: 5px 8px;
border: none;
border-right: 1px solid var(--border);
background: transparent;
font-size: 11px;
font-family: inherit;
color: var(--text2);
cursor: pointer;
white-space: nowrap;
}
.exercise-intensity-segment__btn:last-child {
border-right: none;
}
.exercise-intensity-segment__btn--active {
background: color-mix(in srgb, var(--accent) 16%, var(--surface));
color: var(--accent-dark);
font-weight: 700;
}
.exercise-skill-chip__remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin-left: auto;
padding: 0;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: var(--text3);
font-size: 12px;
cursor: pointer;
flex-shrink: 0;
}
.exercise-skill-chip__remove:hover {
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 35%, var(--border));
}
.skills-editor-row {
display: grid;
grid-template-columns: 1fr auto;

View File

@ -0,0 +1,97 @@
import React from 'react'
/**
* Kompakte Katalog-Zuordnung (Fokus, Stile, Zielgruppen ) als Chip-Zeilen.
*/
export default function ExerciseCatalogAssocEditor({
title,
rows,
setRows,
options,
idKey,
emptyLabel,
showPrimary = true,
}) {
const setPrimary = (idx) => {
setRows(rows.map((r, i) => ({ ...r, is_primary: i === idx })))
}
const updateRow = (idx, patch) => {
const next = rows.map((r, i) => (i === idx ? { ...r, ...patch } : r))
if (patch.is_primary === true) {
next.forEach((r, i) => {
if (i !== idx) r.is_primary = false
})
}
setRows(next)
}
const addRow = () => setRows([...rows, { [idKey]: '', is_primary: rows.length === 0 }])
const removeRow = (idx) => {
const next = rows.filter((_, i) => i !== idx)
if (next.length && showPrimary && !next.some((r) => r.is_primary)) next[0].is_primary = true
setRows(next)
}
const optionLabel = (o) => {
const parts = []
if (o.icon) parts.push(o.icon)
parts.push(o.name)
if (o.abbreviation) parts.push(`(${o.abbreviation})`)
return parts.join(' ')
}
return (
<div className="exercise-meta-block">
<div className="exercise-meta-block__head">
<h4 className="exercise-meta-block__title">{title}</h4>
<button type="button" className="btn btn-secondary exercise-meta-block__add" onClick={addRow}>
+ Eintrag
</button>
</div>
{rows.length === 0 ? (
<p className="exercise-meta-block__empty">{emptyLabel}</p>
) : (
<div className="exercise-meta-block__chips" role="list">
{rows.map((row, idx) => (
<div key={idx} className="exercise-catalog-chip" role="listitem">
<select
className="exercise-catalog-chip__select"
value={row[idKey] || ''}
aria-label={`${title} wählen`}
onChange={(e) =>
updateRow(idx, { [idKey]: e.target.value ? parseInt(e.target.value, 10) : '' })
}
>
<option value=""> wählen </option>
{options.map((o) => (
<option key={o.id} value={o.id}>
{optionLabel(o)}
</option>
))}
</select>
{showPrimary ? (
<button
type="button"
className={`exercise-catalog-chip__primary${row.is_primary ? ' exercise-catalog-chip__primary--on' : ''}`}
title={row.is_primary ? 'Primär' : 'Als primär markieren'}
aria-pressed={!!row.is_primary}
onClick={() => setPrimary(idx)}
>
</button>
) : null}
<button
type="button"
className="exercise-catalog-chip__remove"
aria-label="Entfernen"
title="Entfernen"
onClick={() => removeRow(idx)}
>
</button>
</div>
))}
</div>
)}
</div>
)
}

View File

@ -14,9 +14,9 @@ import {
buildExerciseMediaDragPayload,
} from '../../utils/exerciseInlineMediaRefs'
import { autoScrollForDragNearEdges } from '../../utils/dragAutoScroll'
import SkillTreeSelect from '../SkillTreeSelect'
import { skillCatalogPathLabel } from '../../utils/skillCatalogTree'
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../../constants/skillLevels'
import { normalizeSkillLevelSlug } from '../../constants/skillLevels'
import ExerciseCatalogAssocEditor from './ExerciseCatalogAssocEditor'
import ExerciseSkillsEditor from './ExerciseSkillsEditor'
import { useAuth } from '../../context/AuthContext'
import { useToast } from '../../context/ToastContext'
import {
@ -41,7 +41,6 @@ import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../../hooks/useUn
import {
EXERCISE_SKILL_INTENSITY_DEFAULT,
EXERCISE_SKILL_INTENSITY_OPTIONS,
normalizeExerciseSkillIntensity,
} from '../../constants/exerciseSkillIntensity'
@ -409,71 +408,6 @@ function detailToForm(exercise) {
}
}
function MultiAssocBlock({ title, rows, setRows, options, idKey, emptyLabel }) {
const setPrimary = (idx) => {
setRows(rows.map((r, i) => ({ ...r, is_primary: i === idx })))
}
const updateRow = (idx, patch) => {
const next = rows.map((r, i) => (i === idx ? { ...r, ...patch } : r))
if (patch.is_primary === true) {
next.forEach((r, i) => {
if (i !== idx) r.is_primary = false
})
}
setRows(next)
}
const addRow = () => setRows([...rows, { [idKey]: '', is_primary: rows.length === 0 }])
const removeRow = (idx) => {
const next = rows.filter((_, i) => i !== idx)
if (next.length && !next.some((r) => r.is_primary)) next[0].is_primary = true
setRows(next)
}
return (
<div className="multi-assoc-block">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' }}>
<h3>{title}</h3>
<button type="button" className="btn btn-secondary" style={{ fontSize: '12px', padding: '4px 10px' }} onClick={addRow}>
+ Eintrag
</button>
</div>
{rows.length === 0 && (
<p style={{ fontSize: '13px', color: 'var(--text2)', margin: 0 }}>{emptyLabel}</p>
)}
{rows.map((row, idx) => (
<div key={idx} className="multi-assoc-row">
<select
className="form-input"
value={row[idKey] || ''}
onChange={(e) => updateRow(idx, { [idKey]: e.target.value ? parseInt(e.target.value, 10) : '' })}
>
<option value=""> wählen </option>
{options.map((o) => (
<option key={o.id} value={o.id}>
{o.icon ? `${o.icon} ` : ''}
{o.name}
{o.abbreviation ? ` (${o.abbreviation})` : ''}
</option>
))}
</select>
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '13px', whiteSpace: 'nowrap' }}>
<input
type="radio"
name={`primary-${idKey}`}
checked={!!row.is_primary}
onChange={() => setPrimary(idx)}
/>
primär
</label>
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => removeRow(idx)}>
</button>
</div>
))}
</div>
)
}
function ExerciseFormPageRoot() {
const { id: routeId } = useParams()
const navigate = useNavigate()
@ -1806,114 +1740,62 @@ function ExerciseFormPageRoot() {
</div>
</div>
<MultiAssocBlock
title="Fokusbereiche (0…n, ein „primär“)"
rows={formData.focus_areas_multi}
setRows={(r) => updateFormField('focus_areas_multi', r)}
options={focusAreas}
idKey="focus_area_id"
emptyLabel="Keine Zuordnung — optional „+ Eintrag“."
/>
<MultiAssocBlock
title="Stilrichtungen (0…n, z. B. Shotokan)"
rows={formData.training_styles_multi}
setRows={(r) => updateFormField('training_styles_multi', r)}
options={styleDirections.map((sd) => ({
...sd,
name: sd.parent_style_name ? `${sd.name} (${sd.parent_style_name})` : sd.name,
}))}
idKey="training_style_id"
emptyLabel="Keine Stilrichtung gewählt."
/>
<MultiAssocBlock
title="Trainingsstil (0…n, z. B. Breitensport / Leistungssport)"
rows={formData.training_types_multi}
setRows={(r) => updateFormField('training_types_multi', r)}
options={trainingTypes}
idKey="training_type_id"
emptyLabel="Kein Trainingsstil gewählt."
/>
<MultiAssocBlock
title="Zielgruppen (0…n)"
rows={formData.target_groups_multi}
setRows={(r) => updateFormField('target_groups_multi', r)}
options={targetGroups}
idKey="target_group_id"
emptyLabel="Keine Zielgruppe gewählt."
/>
<div className="form-row">
<label className="form-label">Fähigkeiten (je Übung mehrere, mit Niveau)</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '10px', alignItems: 'stretch' }}>
<SkillTreeSelect
value={skillPick}
onChange={setSkillPick}
skills={skillsCatalog}
excludeIds={formData.skills.map((s) => s.skill_id)}
placeholder="Fähigkeit wählen…"
<section className="exercise-form-meta-panel" aria-labelledby="exercise-meta-heading">
<h3 id="exercise-meta-heading" className="exercise-form-meta-panel__title">
Klassifikation &amp; Zielgruppe
</h3>
<div className="exercise-form-meta-panel__grid">
<ExerciseCatalogAssocEditor
title="Fokusbereiche"
rows={formData.focus_areas_multi}
setRows={(r) => updateFormField('focus_areas_multi', r)}
options={focusAreas}
idKey="focus_area_id"
emptyLabel="Optional — „+ Eintrag“."
/>
<ExerciseCatalogAssocEditor
title="Stilrichtungen"
rows={formData.training_styles_multi}
setRows={(r) => updateFormField('training_styles_multi', r)}
options={styleDirections.map((sd) => ({
...sd,
name: sd.parent_style_name ? `${sd.name} (${sd.parent_style_name})` : sd.name,
}))}
idKey="training_style_id"
emptyLabel="Optional."
/>
<ExerciseCatalogAssocEditor
title="Trainingsstil"
rows={formData.training_types_multi}
setRows={(r) => updateFormField('training_types_multi', r)}
options={trainingTypes}
idKey="training_type_id"
emptyLabel="Optional."
/>
<ExerciseCatalogAssocEditor
title="Zielgruppen"
rows={formData.target_groups_multi}
setRows={(r) => updateFormField('target_groups_multi', r)}
options={targetGroups}
idKey="target_group_id"
emptyLabel="Optional."
showPrimary={false}
/>
<button type="button" className="btn btn-secondary" onClick={addSkillRow}>
Hinzufügen
</button>
</div>
{formData.skills.map((row, idx) => {
const sk = skillsCatalog.find((s) => s.id === row.skill_id)
return (
<div key={`${row.skill_id}-${idx}`} className="skills-editor-row">
<div>
<strong style={{ fontSize: '14px' }}>{sk?.name || `Skill #${row.skill_id}`}</strong>
{sk ? (
<span style={{ color: 'var(--text2)', fontSize: '12px', marginLeft: '6px' }}>
{skillCatalogPathLabel(sk)}
</span>
) : null}
</div>
<label className="exercise-filter-skill-level-field">
<span className="exercise-filter-skill-level-caption">Intensität</span>
<select
className="form-input"
value={normalizeExerciseSkillIntensity(row.intensity)}
onChange={(e) => updateSkillField(idx, 'intensity', e.target.value)}
>
{EXERCISE_SKILL_INTENSITY_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
<select
className="form-input"
value={row.required_level || ''}
onChange={(e) => updateSkillField(idx, 'required_level', e.target.value)}
>
{SKILL_LEVEL_OPTIONS.map((o) => (
<option key={`r-${o.value}`} value={o.value}>
von {o.label}
</option>
))}
</select>
<select
className="form-input"
value={row.target_level || ''}
onChange={(e) => updateSkillField(idx, 'target_level', e.target.value)}
>
{SKILL_LEVEL_OPTIONS.map((o) => (
<option key={`t-${o.value}`} value={o.value}>
bis {o.label}
</option>
))}
</select>
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => removeSkillRow(idx)}>
Entf.
</button>
</div>
)
})}
</div>
<ExerciseSkillsEditor
rows={formData.skills}
skillsCatalog={skillsCatalog}
skillPick={skillPick}
onSkillPickChange={setSkillPick}
onAdd={addSkillRow}
onRemove={removeSkillRow}
onUpdateField={updateSkillField}
/>
</section>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div className="form-row">

View File

@ -0,0 +1,134 @@
import React from 'react'
import SkillTreeSelect from '../SkillTreeSelect'
import { skillCatalogPathLabel } from '../../utils/skillCatalogTree'
import { SKILL_LEVEL_OPTIONS } from '../../constants/skillLevels'
import {
EXERCISE_SKILL_INTENSITY_DEFAULT,
EXERCISE_SKILL_INTENSITY_OPTIONS,
normalizeExerciseSkillIntensity,
} from '../../constants/exerciseSkillIntensity'
export default function ExerciseSkillsEditor({
rows,
skillsCatalog,
skillPick,
onSkillPickChange,
onAdd,
onRemove,
onUpdateField,
}) {
return (
<div className="exercise-meta-block exercise-meta-block--skills">
<div className="exercise-meta-block__head">
<h4 className="exercise-meta-block__title">Fähigkeiten</h4>
</div>
<p className="exercise-meta-block__hint">Je Übung mehrere Fähigkeiten mit Intensität und Niveau (vonbis).</p>
<div className="exercise-skills-add">
<SkillTreeSelect
value={skillPick}
onChange={onSkillPickChange}
skills={skillsCatalog}
excludeIds={rows.map((s) => s.skill_id)}
placeholder="Fähigkeit wählen…"
/>
<button type="button" className="btn btn-secondary" onClick={onAdd}>
Hinzufügen
</button>
</div>
{rows.length === 0 ? (
<p className="exercise-meta-block__empty">Noch keine Fähigkeit zugeordnet.</p>
) : (
<ul className="exercise-skills-list">
{rows.map((row, idx) => {
const sk = skillsCatalog.find((s) => s.id === row.skill_id)
const intensity = normalizeExerciseSkillIntensity(row.intensity)
return (
<li key={`${row.skill_id}-${idx}`} className="exercise-skill-chip">
<div className="exercise-skill-chip__identity">
<span className="exercise-skill-chip__name">{sk?.name || `Skill #${row.skill_id}`}</span>
{sk ? (
<span className="exercise-skill-chip__path" title={skillCatalogPathLabel(sk)}>
{skillCatalogPathLabel(sk)}
</span>
) : null}
</div>
<div className="exercise-skill-chip__controls">
<div className="exercise-skill-chip__field">
<span className="exercise-skill-chip__caption">Intensität</span>
<div className="exercise-intensity-segment" role="radiogroup" aria-label="Intensität">
{EXERCISE_SKILL_INTENSITY_OPTIONS.map((o) => (
<button
key={o.value}
type="button"
role="radio"
aria-checked={intensity === o.value}
className={`exercise-intensity-segment__btn${
intensity === o.value ? ' exercise-intensity-segment__btn--active' : ''
}`}
onClick={() => onUpdateField(idx, 'intensity', o.value)}
>
{o.label}
</button>
))}
</div>
</div>
<div className="exercise-skill-chip__levels">
<label className="exercise-skill-chip__field">
<span className="exercise-skill-chip__caption">von</span>
<select
className="form-input exercise-skill-level-select"
value={row.required_level || ''}
title="Mindest-Niveau"
onChange={(e) => onUpdateField(idx, 'required_level', e.target.value)}
>
{SKILL_LEVEL_OPTIONS.map((o) => (
<option key={`r-${o.value}`} value={o.value} title={o.label}>
{o.level != null ? o.level : ''}
</option>
))}
</select>
</label>
<span className="exercise-skill-chip__dash" aria-hidden>
</span>
<label className="exercise-skill-chip__field">
<span className="exercise-skill-chip__caption">bis</span>
<select
className="form-input exercise-skill-level-select"
value={row.target_level || ''}
title="Ziel-Niveau"
onChange={(e) => onUpdateField(idx, 'target_level', e.target.value)}
>
{SKILL_LEVEL_OPTIONS.map((o) => (
<option key={`t-${o.value}`} value={o.value} title={o.label}>
{o.level != null ? o.level : ''}
</option>
))}
</select>
</label>
</div>
<button
type="button"
className="exercise-skill-chip__remove"
aria-label="Fähigkeit entfernen"
title="Entfernen"
onClick={() => onRemove(idx)}
>
</button>
</div>
</li>
)
})}
</ul>
)}
</div>
)
}
export { EXERCISE_SKILL_INTENSITY_DEFAULT }