diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 7ba23ba..c7c4f6c 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -54,6 +54,7 @@ class ExerciseCreate(BaseModel): # M:N Relations (Liste von {id: int, is_primary: bool}) focus_areas_multi: list[dict] = [] training_styles_multi: list[dict] = [] + training_types_multi: list[dict] = [] target_groups_multi: list[dict] = [] age_groups: list[str] = [] # ["Kinder", "Teenager"] aus Katalog @@ -91,6 +92,7 @@ class ExerciseUpdate(BaseModel): equipment: Optional[list[str]] = None focus_areas_multi: Optional[list[dict]] = None training_styles_multi: Optional[list[dict]] = None + training_types_multi: Optional[list[dict]] = None target_groups_multi: Optional[list[dict]] = None age_groups: Optional[list[str]] = None skills: Optional[list[dict]] = None @@ -227,6 +229,17 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict: ) exercise["training_styles"] = [r2d(r) for r in cur.fetchall()] + # Trainingsstil (Breitensport / Leistungssport …) — exercise_training_types + cur.execute( + """SELECT ett.id, ett.training_type_id, tt.name, tt.abbreviation, ett.is_primary + FROM exercise_training_types ett + JOIN training_types tt ON ett.training_type_id = tt.id + WHERE ett.exercise_id = %s + ORDER BY ett.is_primary DESC, tt.sort_order NULLS LAST, tt.name""", + (exercise_id,), + ) + exercise["training_types"] = [r2d(r) for r in cur.fetchall()] + # Target Groups (M:N) cur.execute( """SELECT etg.id, etg.target_group_id, tg.name, tg.description, etg.is_primary @@ -301,7 +314,7 @@ def assign_exercise_relations(cur, conn, exercise_id: int, data: dict): (exercise_id, fa["focus_area_id"], fa.get("is_primary", False)) ) - # Training Styles + # Training Styles (Stilrichtungen, z. B. Shotokan) if "training_styles_multi" in data: cur.execute("DELETE FROM exercise_style_directions WHERE exercise_id = %s", (exercise_id,)) for ts in data["training_styles_multi"]: @@ -311,6 +324,16 @@ def assign_exercise_relations(cur, conn, exercise_id: int, data: dict): (exercise_id, ts["training_style_id"], ts.get("is_primary", False)) ) + # Trainingsstil (Breitensport, Leistungssport, …) + if "training_types_multi" in data: + cur.execute("DELETE FROM exercise_training_types WHERE exercise_id = %s", (exercise_id,)) + for tt in data["training_types_multi"]: + cur.execute( + """INSERT INTO exercise_training_types (exercise_id, training_type_id, is_primary) + VALUES (%s, %s, %s)""", + (exercise_id, tt["training_type_id"], tt.get("is_primary", False)), + ) + # Target Groups if "target_groups_multi" in data: cur.execute("DELETE FROM exercise_target_groups WHERE exercise_id = %s", (exercise_id,)) diff --git a/frontend/src/app.css b/frontend/src/app.css index 9e7c2ee..d4b2d59 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -2217,3 +2217,194 @@ a.analysis-split__nav-item { gap: 10px; } } + +/* --- Übungen: Rich-Text & Kacheln --- */ +.rich-text-editor-wrap { + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + background: var(--surface); +} +.rich-text-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + padding: 6px 8px; + background: var(--surface2); + border-bottom: 1px solid var(--border); +} +.rte-btn { + font-size: 12px; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--surface); + color: var(--text1); + cursor: pointer; + line-height: 1.2; +} +.rte-btn:active { + background: var(--accent-light); +} +.rte-sep { + width: 1px; + height: 18px; + background: var(--border); + margin: 0 2px; +} +.rich-text-editor { + padding: 10px 12px; + outline: none; + font-size: 15px; + line-height: 1.5; + max-height: 50vh; + overflow-y: auto; +} +.rich-text-editor:empty:before { + content: attr(data-placeholder); + color: var(--text3); + pointer-events: none; +} + +.rich-text-content { + font-size: 16px; + line-height: 1.55; + word-break: break-word; +} +.rich-text-content h3 { + font-size: 1.05rem; + margin: 0.75rem 0 0.35rem; +} +.rich-text-content p { + margin: 0.4rem 0; +} +.rich-text-content ul, +.rich-text-content ol { + margin: 0.4rem 0; + padding-left: 1.25rem; +} +.rich-text-content a { + color: var(--accent-dark); +} + +.exercise-card { + display: flex; + flex-direction: column; + min-height: 200px; +} +.exercise-card__body { + flex: 1 1 auto; +} +.exercise-card__actions { + flex-shrink: 0; + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid var(--border); +} +.exercise-card__actions .btn, +.exercise-card__actions a.btn { + flex: 1 1 auto; + min-width: 0; + padding: 6px 10px; + font-size: 13px; +} + +.exercise-tag-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} +.exercise-tag { + display: inline-block; + font-size: 11px; + font-weight: 600; + padding: 3px 8px; + border-radius: 999px; + background: var(--surface2); + color: var(--text2); + border: 1px solid var(--border); +} +.exercise-tag--accent { + background: var(--accent-light); + color: var(--accent-dark); + border-color: transparent; +} + +.exercise-detail-shell { + max-width: 640px; + margin: 0 auto; +} +.exercise-detail-section { + margin-bottom: 14px; +} +.exercise-detail-section h2 { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text3); + margin: 0 0 6px; + font-weight: 700; +} +.exercise-meta-line { + font-size: 14px; + color: var(--text2); + margin: 8px 0 0; +} + +.exercise-filters-compact { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 8px; + margin-bottom: 12px; +} +.exercise-filters-compact .form-label { + font-size: 12px; + margin-bottom: 4px; +} +.exercise-filters-compact .form-input { + padding: 8px 10px; + font-size: 14px; +} + +.multi-assoc-block { + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px; + margin-bottom: 12px; + background: var(--surface2); +} +.multi-assoc-block h3 { + font-size: 14px; + margin: 0 0 8px; +} +.multi-assoc-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} +.multi-assoc-row select { + flex: 1 1 160px; + min-width: 0; +} + +.skills-editor-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; + align-items: start; + padding: 8px 0; + border-bottom: 1px solid var(--border); +} +@media (min-width: 640px) { + .skills-editor-row { + grid-template-columns: 1fr repeat(4, minmax(0, 100px)) auto; + align-items: center; + } +} diff --git a/frontend/src/components/RichTextEditor.jsx b/frontend/src/components/RichTextEditor.jsx new file mode 100644 index 0000000..5340cd1 --- /dev/null +++ b/frontend/src/components/RichTextEditor.jsx @@ -0,0 +1,93 @@ +import React, { useRef, useEffect, useState, useCallback } from 'react' + +function exec(cmd, value = null) { + try { + document.execCommand(cmd, false, value) + } catch (_) { + /* ignore */ + } +} + +/** + * Leichter WYSIWYG-Editor (contenteditable) — ohne zusätzliche npm-Pakete. + * Speichert HTML; Anzeige über sanitizeTrainerHtml + dangerouslySetInnerHTML. + */ +export default function RichTextEditor({ value, onChange, placeholder, minHeight = '140px' }) { + const ref = useRef(null) + const [focused, setFocused] = useState(false) + const lastExternal = useRef(value) + + useEffect(() => { + if (!ref.current) return + if (focused) return + if (value !== lastExternal.current) { + lastExternal.current = value + if (ref.current.innerHTML !== (value || '')) { + ref.current.innerHTML = value || '' + } + } + }, [value, focused]) + + const sync = useCallback(() => { + if (!ref.current) return + const html = ref.current.innerHTML + lastExternal.current = html + onChange(html) + }, [onChange]) + + const onLink = () => { + const url = window.prompt('Link-URL (https://…)') + if (url) exec('createLink', url) + sync() + } + + return ( +
{msg}
@@ -100,147 +160,127 @@ function ExerciseDetailPage() { if (!exercise) return null - const chips = (items, labelKey = 'name') => - (items || []).length ? (items || []).map((x) => x[labelKey]).join(', ') : '—' + const meta = metaParts(exercise) return ( -{exercise.summary}
- )} -- Fokusbereiche: {chips(exercise.focus_areas)} -
-- Stilrichtungen: {chips(exercise.training_styles)} -
-- Zielgruppen: {chips(exercise.target_groups)} -
-- Altersgruppen:{' '} - {(exercise.age_groups || []).length ? exercise.age_groups.join(', ') : '—'} -
-{exercise.goal}
-{exercise.execution}
-{exercise.preparation}
- > - )} - {exercise.trainer_notes && ( - <> -{exercise.trainer_notes}
- > - )} -{v.description}
} - {v.execution_changes && ( -{v.execution_changes}
- )} -{m.description}
} -{meta.join(' · ')}
} +{m.description}
} +{v.description}
} + {v.execution_changes && ( +{emptyLabel}
+ )} + {rows.map((row, idx) => ( +