From 5096eec16b6390d52a7a91c57b0086f406ceac58 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 6 May 2026 12:20:22 +0200 Subject: [PATCH] feat: enhance Exercises and Clubs pages with improved UI and functionality - Added new utility functions for handling exercise focus areas, style directions, and training types, improving data presentation. - Refactored ExercisesListPage to utilize new card layouts and improved visibility labels for exercises. - Updated ClubsPage and SkillsPage to implement a consistent tab navigation style, enhancing user experience. - Enhanced CSS styles for better responsiveness and visual consistency across various components. - Improved loading states and accessibility features for better user feedback and interaction. --- backend/routers/exercises.py | 50 ++++- frontend/src/app.css | 144 +++++++++++-- .../src/pages/AdminMaturityModelsPage.jsx | 18 +- frontend/src/pages/ClubsPage.jsx | 70 +++--- frontend/src/pages/ExercisesListPage.jsx | 202 +++++++++++------- frontend/src/pages/SkillsPage.jsx | 43 ++-- frontend/src/utils/sanitizeHtml.js | 52 +++++ 7 files changed, 425 insertions(+), 154 deletions(-) create mode 100644 frontend/src/utils/sanitizeHtml.js diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index fc4cef0..6ac2b89 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -26,6 +26,24 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", tags=["exercises"]) + +def _coerce_json_str_list(val: Any) -> List[str]: + """JSON-Aggregat oder JSON-String aus PG in eine saubere str-Liste für die Listen-API.""" + if val is None: + return [] + if isinstance(val, list): + return [str(x) for x in val if x is not None and str(x).strip()] + if isinstance(val, str): + try: + parsed = json.loads(val) + if isinstance(parsed, list): + return [str(x) for x in parsed if x is not None and str(x).strip()] + except Exception: + return [] + return [] + return [] + + # Kanonische Fähigkeitsstufen 1–5 (Übung ↔ Skill-Zeile), siehe Migration 029 _CANONICAL_SKILL_LEVELS = frozenset( {"basis", "grundlagen", "aufbau", "fortgeschritten", "optimierung"} @@ -971,7 +989,34 @@ def list_exercises( WHERE efa.exercise_id = e.id ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC LIMIT 1 - ) AS primary_focus_name + ) AS primary_focus_name, + ( + SELECT COALESCE( + json_agg(fa.name ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC), + '[]'::json + ) + FROM exercise_focus_areas efa + JOIN focus_areas fa ON fa.id = efa.focus_area_id + WHERE efa.exercise_id = e.id + ) AS focus_area_names, + ( + SELECT COALESCE( + json_agg(sd.name ORDER BY esd.is_primary DESC NULLS LAST, sd.name ASC), + '[]'::json + ) + FROM exercise_style_directions esd + JOIN style_directions sd ON sd.id = esd.style_direction_id + WHERE esd.exercise_id = e.id + ) AS style_direction_names, + ( + SELECT COALESCE( + json_agg(tt.name ORDER BY ett.is_primary DESC NULLS LAST, tt.sort_order NULLS LAST, tt.name ASC), + '[]'::json + ) + 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 {variants_sql} FROM exercises e LEFT JOIN profiles p ON e.created_by = p.id @@ -990,6 +1035,9 @@ def list_exercises( d = r2d(r) pfn = d.get("primary_focus_name") d["focus_area"] = pfn + 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")) if include_variants: v = d.get("variants") if isinstance(v, str): diff --git a/frontend/src/app.css b/frontend/src/app.css index bea00ec..e3a636a 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1442,12 +1442,6 @@ button.capture-shell__nav-item { .skills-page__tabs-scroll::-webkit-scrollbar { display: none; } - - .exercises-page-mode-switch, - .skills-page-mode-switch { - width: max(100%, min(20rem, 100vw - 24px)); - max-width: none; - } } /* Trainingsplanung: kompakte Segmente (Gruppe / Verein) */ @@ -2463,6 +2457,12 @@ button.capture-shell__nav-item { flex: 1; } +.clubs-page__intro { + margin: 0 0 1.25rem; + max-width: 46rem; + line-height: 1.55; +} + /* Übungsliste: Kopf, Modus-Segmente, Hinweise */ .exercises-page__header { display: flex; @@ -2478,11 +2478,6 @@ button.capture-shell__nav-item { .exercises-page-toolbar-tabs { margin-bottom: 14px; } -.exercises-page-mode-switch, -.skills-page-mode-switch { - width: 100%; - max-width: min(100%, 28rem); -} .exercise-search-hint { font-size: 12px; color: var(--text3); @@ -2515,14 +2510,24 @@ button.capture-shell__nav-item { } .exercises-list-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr)); - gap: 12px; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); + gap: 14px; + align-items: stretch; +} +.exercises-list-grid > .exercise-card { + height: 100%; + min-height: 0; } .exercise-card-layout { display: flex; gap: 10px; align-items: flex-start; } +.exercise-card-layout--grow { + flex: 1 1 auto; + min-height: 0; + width: 100%; +} .exercise-card-layout__check { margin-top: 4px; flex-shrink: 0; @@ -2562,6 +2567,28 @@ button.capture-shell__nav-item { line-height: 1.4; margin: 0; } +.exercise-card-summary--rich { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; + overflow: hidden; + word-break: break-word; +} +.exercise-card-summary--rich b, +.exercise-card-summary--rich strong { + font-weight: 700; + color: var(--text1); +} +.exercise-card-summary--rich i, +.exercise-card-summary--rich em { + font-style: italic; +} +.exercise-card-summary--rich p { + margin: 0 0 0.35em; +} +.exercise-card-summary--rich p:last-child { + margin-bottom: 0; +} .exercises-meta-line { font-size: 13px; color: var(--text2); @@ -3665,7 +3692,31 @@ button.capture-shell__nav-item { .exercise-card { display: flex; flex-direction: column; - min-height: 200px; + min-height: 0; + border-left: 4px solid var(--border2); + transition: border-color 0.15s, box-shadow 0.15s; +} +.exercise-card--scope-official { + border-left-color: var(--accent); + background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 7%, var(--surface)) 0%, var(--surface) 64%); +} +.exercise-card--scope-club { + border-left-color: var(--warn); + background: linear-gradient(180deg, color-mix(in srgb, var(--warn) 10%, var(--surface)) 0%, var(--surface) 64%); +} +.exercise-card--scope-private { + border-left-color: var(--text3); +} +.exercise-card--mine { + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 28%, var(--border)); +} +@media (prefers-color-scheme: dark) { + .exercise-card--scope-official { + background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 12%, var(--surface)) 0%, var(--surface) 64%); + } + .exercise-card--scope-club { + background: linear-gradient(180deg, color-mix(in srgb, var(--warn) 12%, var(--surface)) 0%, var(--surface) 64%); + } } .exercise-card__body { flex: 1 1 auto; @@ -3714,10 +3765,51 @@ button.capture-shell__nav-item { display: flex; gap: 6px; flex-wrap: wrap; - margin-top: 12px; + margin-top: auto; padding-top: 10px; border-top: 1px solid var(--border); } +.exercise-card__actions--icons { + justify-content: flex-end; + gap: 8px; + flex-wrap: nowrap; +} +.exercise-card__icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + border-radius: 8px; + border: 1px solid var(--border2); + background: var(--surface2); + color: var(--text1); + text-decoration: none; + cursor: pointer; + flex-shrink: 0; + transition: background 0.12s, border-color 0.12s, color 0.12s; + -webkit-tap-highlight-color: transparent; +} +.exercise-card__icon-btn:hover { + background: var(--surface); + border-color: var(--accent); + color: var(--accent-dark); +} +@media (prefers-color-scheme: dark) { + .exercise-card__icon-btn:hover { + color: var(--accent); + } +} +.exercise-card__icon-btn--danger { + color: var(--danger); + border-color: color-mix(in srgb, var(--danger) 35%, var(--border2)); +} +.exercise-card__icon-btn--danger:hover { + background: color-mix(in srgb, var(--danger) 10%, var(--surface2)); + border-color: var(--danger); + color: var(--danger); +} .exercise-card__actions .btn, .exercise-card__actions a.btn { flex: 1 1 auto; @@ -3747,6 +3839,28 @@ button.capture-shell__nav-item { color: var(--accent-dark); border-color: transparent; } +.exercise-tag--style { + background: color-mix(in srgb, var(--accent) 12%, var(--surface2)); + color: var(--accent-dark); + border-color: color-mix(in srgb, var(--accent) 22%, var(--border)); +} +.exercise-tag--training { + background: var(--surface2); + color: var(--text1); + border-color: var(--border2); +} +.exercise-tag--scope { + font-weight: 700; + background: var(--surface); + color: var(--text2); +} +.exercise-tag--meta { + font-weight: 600; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--text3); +} .exercise-detail-shell { max-width: none; diff --git a/frontend/src/pages/AdminMaturityModelsPage.jsx b/frontend/src/pages/AdminMaturityModelsPage.jsx index a1087cf..109a29f 100644 --- a/frontend/src/pages/AdminMaturityModelsPage.jsx +++ b/frontend/src/pages/AdminMaturityModelsPage.jsx @@ -27,12 +27,14 @@ export default function AdminMaturityModelsPage() {

-
+
- ))} -
+ return ( +
+

Vereinsverwaltung

+

+ Für die Trainingsplanung wird mindestens ein Verein und eine Trainingsgruppe gebraucht. + Sparten sind optional — typische Eckdaten einer Gruppe (Wochentag, Zeit, Ort) kannst du schrittweise eintragen. +

+ +
+ {clubTabIds.map((tab) => ( + + ))} +
{/* Clubs Tab */} {activeTab === 'clubs' && ( diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx index e90a586..2bfaa37 100644 --- a/frontend/src/pages/ExercisesListPage.jsx +++ b/frontend/src/pages/ExercisesListPage.jsx @@ -1,10 +1,12 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react' import { Link } from 'react-router-dom' +import { Eye, Pencil, Trash2 } from 'lucide-react' import api from '../utils/api' import { useAuth } from '../context/AuthContext' import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels' import MultiSelectCombo from '../components/MultiSelectCombo' import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel' +import { sanitizeExerciseRichText, coerceApiNameList } from '../utils/sanitizeHtml' const PAGE_SIZE = 100 const BULK_MAX_IDS = 500 @@ -22,6 +24,38 @@ const INITIAL_FILTERS = { status_any: [], } +const VIS_LABELS = { official: 'Global', club: 'Verein', private: 'Privat' } +const STATUS_LABELS = { + draft: 'Entwurf', + in_review: 'In Prüfung', + approved: 'Freigegeben', + archived: 'Archiv', +} + +function visibilityLabel(v) { + return VIS_LABELS[v] || v || '—' +} + +function statusLabel(s) { + return STATUS_LABELS[s] || s || '—' +} + +function exerciseFocusNames(ex) { + const fromApi = coerceApiNameList(ex.focus_area_names) + if (fromApi.length) return fromApi + if (ex.focus_area) return [ex.focus_area] + return [] +} + +function exerciseCardClassName(exercise, userId) { + const vis = exercise.visibility || 'private' + const visKey = vis === 'official' || vis === 'club' || vis === 'private' ? vis : 'private' + const mine = userId != null && Number(exercise.created_by) === Number(userId) + return ['card', 'exercise-card', `exercise-card--scope-${visKey}`, mine ? 'exercise-card--mine' : ''] + .filter(Boolean) + .join(' ') +} + function levelOptionShort(levelStr) { const o = LEVEL_FILTER_OPTS.find((x) => String(x.level) === String(levelStr)) return o ? String(o.level) : String(levelStr) @@ -569,33 +603,30 @@ function ExercisesListPage() { )}
-
-
- - -
+
+ +
{pageTab === 'progression' ? ( @@ -1093,55 +1124,80 @@ function ExercisesListPage() { {hasMore ? ' · es gibt weitere Einträge' : ''}

- {exercises.map((exercise) => ( -
-
- toggleSelect(exercise.id)} - aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`} - className="exercise-card-layout__check" - /> -
-

- - {exercise.title} + {exercises.map((exercise) => { + const focusNames = exerciseFocusNames(exercise) + const styleNames = coerceApiNameList(exercise.style_direction_names) + const typeNames = coerceApiNameList(exercise.training_type_names) + const summaryHtml = exercise.summary + ? sanitizeExerciseRichText(exercise.summary) + : '' + return ( +
+
+ toggleSelect(exercise.id)} + aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`} + className="exercise-card-layout__check" + /> +
+

+ + {exercise.title} + +

+
+ {focusNames.map((name) => ( + {name} + ))} + {styleNames.map((name) => ( + {name} + ))} + {typeNames.map((name) => ( + {name} + ))} + {visibilityLabel(exercise.visibility)} + {statusLabel(exercise.status)} +
+ {summaryHtml ? ( +
+ ) : null} +
+
+
+ + -

-
- {exercise.focus_area && ( - {exercise.focus_area} - )} - {exercise.visibility} - {exercise.status} -
- {exercise.summary && ( -

- {exercise.summary.length > 160 - ? `${exercise.summary.slice(0, 160)}…` - : exercise.summary} -

- )} + + + +
-
- - Ansehen - - - Bearbeiten - - -
-
- ))} + ) + })}
{hasMore && (
diff --git a/frontend/src/pages/SkillsPage.jsx b/frontend/src/pages/SkillsPage.jsx index ab977a0..626c5b0 100644 --- a/frontend/src/pages/SkillsPage.jsx +++ b/frontend/src/pages/SkillsPage.jsx @@ -146,28 +146,29 @@ function SkillsPage() {

Fähigkeiten & Methoden

-
-
+ - ))} -
+ Fähigkeiten + +
{/* Skills Tab */} diff --git a/frontend/src/utils/sanitizeHtml.js b/frontend/src/utils/sanitizeHtml.js new file mode 100644 index 0000000..a2b5a31 --- /dev/null +++ b/frontend/src/utils/sanitizeHtml.js @@ -0,0 +1,52 @@ +/** + * Reduziert HTML aus Übungs-Kurztexten auf eine kleine erlaubte Menge von Tags (ohne Attribute). + * Für Anzeige mit dangerouslySetInnerHTML. + */ +const ALLOWED_TAGS = new Set(['b', 'strong', 'i', 'em', 'br', 'p', 'span', 'ul', 'ol', 'li']) + +function cleanTree(parent) { + const nodes = Array.from(parent.childNodes) + for (const node of nodes) { + if (node.nodeType === Node.TEXT_NODE) continue + if (node.nodeType !== Node.ELEMENT_NODE) { + parent.removeChild(node) + continue + } + const tag = node.tagName.toLowerCase() + if (!ALLOWED_TAGS.has(tag)) { + while (node.firstChild) { + parent.insertBefore(node.firstChild, node) + } + parent.removeChild(node) + continue + } + while (node.attributes.length > 0) { + node.removeAttribute(node.attributes[0].name) + } + cleanTree(node) + } +} + +export function sanitizeExerciseRichText(html) { + if (html == null || typeof html !== 'string') return '' + const trimmed = html.trim() + if (!trimmed) return '' + + const tpl = document.createElement('template') + tpl.innerHTML = trimmed + cleanTree(tpl.content) + return tpl.innerHTML +} + +export function coerceApiNameList(value) { + if (Array.isArray(value)) return value.map(String).filter((s) => s.trim()) + if (typeof value === 'string') { + try { + const p = JSON.parse(value) + if (Array.isArray(p)) return p.map(String).filter((s) => s.trim()) + } catch { + return [] + } + } + return [] +}