From 9b3f5940077709d8a9e040069b78fd175a5f3aa9 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 21 May 2026 14:40:50 +0200 Subject: [PATCH] Implement Exercise Form Enhancements with New Meta Panel - 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. --- frontend/src/app.css | 260 ++++++++++++++++++ .../exercises/ExerciseCatalogAssocEditor.jsx | 97 +++++++ .../exercises/ExerciseFormPageRoot.jsx | 232 ++++------------ .../exercises/ExerciseSkillsEditor.jsx | 134 +++++++++ 4 files changed, 548 insertions(+), 175 deletions(-) create mode 100644 frontend/src/components/exercises/ExerciseCatalogAssocEditor.jsx create mode 100644 frontend/src/components/exercises/ExerciseSkillsEditor.jsx diff --git a/frontend/src/app.css b/frontend/src/app.css index 9feb71f..8e66dd1 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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; diff --git a/frontend/src/components/exercises/ExerciseCatalogAssocEditor.jsx b/frontend/src/components/exercises/ExerciseCatalogAssocEditor.jsx new file mode 100644 index 0000000..c536181 --- /dev/null +++ b/frontend/src/components/exercises/ExerciseCatalogAssocEditor.jsx @@ -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 ( +
+
+

{title}

+ +
+ {rows.length === 0 ? ( +

{emptyLabel}

+ ) : ( +
+ {rows.map((row, idx) => ( +
+ + {showPrimary ? ( + + ) : null} + +
+ ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx index e212c99..84eaac0 100644 --- a/frontend/src/components/exercises/ExerciseFormPageRoot.jsx +++ b/frontend/src/components/exercises/ExerciseFormPageRoot.jsx @@ -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 ( -
-
-

{title}

- -
- {rows.length === 0 && ( -

{emptyLabel}

- )} - {rows.map((row, idx) => ( -
- - - -
- ))} -
- ) -} - function ExerciseFormPageRoot() { const { id: routeId } = useParams() const navigate = useNavigate() @@ -1806,114 +1740,62 @@ function ExerciseFormPageRoot() { - updateFormField('focus_areas_multi', r)} - options={focusAreas} - idKey="focus_area_id" - emptyLabel="Keine Zuordnung — optional „+ Eintrag“." - /> - - 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." - /> - - updateFormField('training_types_multi', r)} - options={trainingTypes} - idKey="training_type_id" - emptyLabel="Kein Trainingsstil gewählt." - /> - - updateFormField('target_groups_multi', r)} - options={targetGroups} - idKey="target_group_id" - emptyLabel="Keine Zielgruppe gewählt." - /> - -
- -
- s.skill_id)} - placeholder="Fähigkeit wählen…" +
+

+ Klassifikation & Zielgruppe +

+
+ updateFormField('focus_areas_multi', r)} + options={focusAreas} + idKey="focus_area_id" + emptyLabel="Optional — „+ Eintrag“." + /> + + 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." + /> + + updateFormField('training_types_multi', r)} + options={trainingTypes} + idKey="training_type_id" + emptyLabel="Optional." + /> + + updateFormField('target_groups_multi', r)} + options={targetGroups} + idKey="target_group_id" + emptyLabel="Optional." + showPrimary={false} /> -
- {formData.skills.map((row, idx) => { - const sk = skillsCatalog.find((s) => s.id === row.skill_id) - return ( -
-
- {sk?.name || `Skill #${row.skill_id}`} - {sk ? ( - - {skillCatalogPathLabel(sk)} - - ) : null} -
- - - - -
- ) - })} -
+ + +
diff --git a/frontend/src/components/exercises/ExerciseSkillsEditor.jsx b/frontend/src/components/exercises/ExerciseSkillsEditor.jsx new file mode 100644 index 0000000..0e2d014 --- /dev/null +++ b/frontend/src/components/exercises/ExerciseSkillsEditor.jsx @@ -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 ( +
+
+

Fähigkeiten

+
+

Je Übung mehrere Fähigkeiten mit Intensität und Niveau (von–bis).

+ +
+ s.skill_id)} + placeholder="Fähigkeit wählen…" + /> + +
+ + {rows.length === 0 ? ( +

Noch keine Fähigkeit zugeordnet.

+ ) : ( +
    + {rows.map((row, idx) => { + const sk = skillsCatalog.find((s) => s.id === row.skill_id) + const intensity = normalizeExerciseSkillIntensity(row.intensity) + return ( +
  • +
    + {sk?.name || `Skill #${row.skill_id}`} + {sk ? ( + + {skillCatalogPathLabel(sk)} + + ) : null} +
    + +
    +
    + Intensität +
    + {EXERCISE_SKILL_INTENSITY_OPTIONS.map((o) => ( + + ))} +
    +
    + +
    + + + → + + +
    + + +
    +
  • + ) + })} +
+ )} +
+ ) +} + +export { EXERCISE_SKILL_INTENSITY_DEFAULT }