feat: enhance Exercises and Clubs pages with improved UI and functionality
Some checks failed
Deploy Development / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Failing after 28s
Some checks failed
Deploy Development / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Failing after 28s
- 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.
This commit is contained in:
parent
68923b0364
commit
5096eec16b
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -27,12 +27,14 @@ export default function AdminMaturityModelsPage() {
|
|||
</p>
|
||||
</header>
|
||||
|
||||
<div className="admin-tabs" role="tablist" aria-label="Bereiche Fähigkeiten">
|
||||
<div className="admin-page-subtabs" role="tablist" aria-label="Bereiche Fähigkeiten">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'catalog'}
|
||||
className={'admin-tabs__tab' + (tab === 'catalog' ? ' admin-tabs__tab--active' : '')}
|
||||
className={
|
||||
'admin-page-subtabs__btn' + (tab === 'catalog' ? ' admin-page-subtabs__btn--active' : '')
|
||||
}
|
||||
onClick={() => setTab('catalog')}
|
||||
>
|
||||
Katalog und Hierarchie
|
||||
|
|
@ -41,7 +43,9 @@ export default function AdminMaturityModelsPage() {
|
|||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'models'}
|
||||
className={'admin-tabs__tab' + (tab === 'models' ? ' admin-tabs__tab--active' : '')}
|
||||
className={
|
||||
'admin-page-subtabs__btn' + (tab === 'models' ? ' admin-page-subtabs__btn--active' : '')
|
||||
}
|
||||
onClick={() => setTab('models')}
|
||||
>
|
||||
Reifegradmodelle
|
||||
|
|
@ -50,7 +54,9 @@ export default function AdminMaturityModelsPage() {
|
|||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'bindings'}
|
||||
className={'admin-tabs__tab' + (tab === 'bindings' ? ' admin-tabs__tab--active' : '')}
|
||||
className={
|
||||
'admin-page-subtabs__btn' + (tab === 'bindings' ? ' admin-page-subtabs__btn--active' : '')
|
||||
}
|
||||
onClick={() => setTab('bindings')}
|
||||
>
|
||||
Kontext-Zuordnung
|
||||
|
|
@ -59,7 +65,9 @@ export default function AdminMaturityModelsPage() {
|
|||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'matrixviz'}
|
||||
className={'admin-tabs__tab' + (tab === 'matrixviz' ? ' admin-tabs__tab--active' : '')}
|
||||
className={
|
||||
'admin-page-subtabs__btn' + (tab === 'matrixviz' ? ' admin-page-subtabs__btn--active' : '')
|
||||
}
|
||||
onClick={() => setTab('matrixviz')}
|
||||
>
|
||||
Matrix-Ansicht und Export
|
||||
|
|
|
|||
|
|
@ -287,52 +287,44 @@ function ClubsPage() {
|
|||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<div className="skills-page__loading">
|
||||
<div className="spinner"></div>
|
||||
<p>Laden...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-page">
|
||||
<h1 style={{ marginBottom: '0.75rem' }}>Vereinsverwaltung</h1>
|
||||
<p style={{ color: 'var(--text2)', marginBottom: '1.35rem', maxWidth: '46rem', lineHeight: 1.55 }}>
|
||||
Für die Trainingsplanung wird mindestens ein <strong>Verein</strong> und eine <strong>Trainingsgruppe</strong> gebraucht.
|
||||
Sparten sind optional — typische Eckdaten einer Gruppe (Wochentag, Zeit, Ort) kannst du schrittweise eintragen.
|
||||
</p>
|
||||
const clubTabIds = canManageOrgSomewhere
|
||||
? ['clubs', 'divisions', 'groups', 'members']
|
||||
: ['clubs', 'divisions', 'groups']
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
marginBottom: '1.5rem',
|
||||
borderBottom: '2px solid var(--border)'
|
||||
}}>
|
||||
{(canManageOrgSomewhere
|
||||
? ['clubs', 'divisions', 'groups', 'members']
|
||||
: ['clubs', 'divisions', 'groups']
|
||||
).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
background: activeTab === tab ? 'var(--accent)' : 'transparent',
|
||||
color: activeTab === tab ? 'white' : 'var(--text1)',
|
||||
border: 'none',
|
||||
borderRadius: '8px 8px 0 0',
|
||||
cursor: 'pointer',
|
||||
fontWeight: activeTab === tab ? 'bold' : 'normal'
|
||||
}}
|
||||
>
|
||||
{tab === 'clubs' && 'Vereine'}
|
||||
{tab === 'divisions' && 'Sparten'}
|
||||
{tab === 'groups' && 'Trainingsgruppen'}
|
||||
{tab === 'members' && 'Mitglieder'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
return (
|
||||
<div className="app-page clubs-page">
|
||||
<h1 className="page-title">Vereinsverwaltung</h1>
|
||||
<p className="clubs-page__intro muted">
|
||||
Für die Trainingsplanung wird mindestens ein <strong>Verein</strong> und eine <strong>Trainingsgruppe</strong> gebraucht.
|
||||
Sparten sind optional — typische Eckdaten einer Gruppe (Wochentag, Zeit, Ort) kannst du schrittweise eintragen.
|
||||
</p>
|
||||
|
||||
<div className="admin-page-subtabs" role="tablist" aria-label="Vereinsverwaltung">
|
||||
{clubTabIds.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab}
|
||||
className={
|
||||
'admin-page-subtabs__btn' + (activeTab === tab ? ' admin-page-subtabs__btn--active' : '')
|
||||
}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
>
|
||||
{tab === 'clubs' && 'Vereine'}
|
||||
{tab === 'divisions' && 'Sparten'}
|
||||
{tab === 'groups' && 'Trainingsgruppen'}
|
||||
{tab === 'members' && 'Mitglieder'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Clubs Tab */}
|
||||
{activeTab === 'clubs' && (
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="exercises-page-toolbar-tabs" role="tablist" aria-label="Übungen Bereiche">
|
||||
<div className="planning-segment-group planning-segment-group--equal exercises-page-mode-switch">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={pageTab === 'list'}
|
||||
className={
|
||||
'planning-segment-group__btn' +
|
||||
(pageTab === 'list' ? ' planning-segment-group__btn--active' : '')
|
||||
}
|
||||
onClick={() => setPageTab('list')}
|
||||
>
|
||||
Liste
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={pageTab === 'progression'}
|
||||
className={
|
||||
'planning-segment-group__btn' +
|
||||
(pageTab === 'progression' ? ' planning-segment-group__btn--active' : '')
|
||||
}
|
||||
onClick={() => setPageTab('progression')}
|
||||
>
|
||||
Progressionsgraphen
|
||||
</button>
|
||||
</div>
|
||||
<div className="exercises-page-toolbar-tabs admin-page-subtabs" role="tablist" aria-label="Übungen Bereiche">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={pageTab === 'list'}
|
||||
className={
|
||||
'admin-page-subtabs__btn' + (pageTab === 'list' ? ' admin-page-subtabs__btn--active' : '')
|
||||
}
|
||||
onClick={() => setPageTab('list')}
|
||||
>
|
||||
Liste
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={pageTab === 'progression'}
|
||||
className={
|
||||
'admin-page-subtabs__btn' +
|
||||
(pageTab === 'progression' ? ' admin-page-subtabs__btn--active' : '')
|
||||
}
|
||||
onClick={() => setPageTab('progression')}
|
||||
>
|
||||
Progressionsgraphen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{pageTab === 'progression' ? (
|
||||
|
|
@ -1093,55 +1124,80 @@ function ExercisesListPage() {
|
|||
{hasMore ? ' · es gibt weitere Einträge' : ''}
|
||||
</p>
|
||||
<div className="exercises-list-grid">
|
||||
{exercises.map((exercise) => (
|
||||
<div key={exercise.id} className="card exercise-card">
|
||||
<div className="exercise-card-layout">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(Number(exercise.id))}
|
||||
onChange={() => toggleSelect(exercise.id)}
|
||||
aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`}
|
||||
className="exercise-card-layout__check"
|
||||
/>
|
||||
<div className="exercise-card__body exercise-card-body-flex">
|
||||
<h3 className="exercise-card-title">
|
||||
<Link to={`/exercises/${exercise.id}`}>
|
||||
{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 (
|
||||
<div key={exercise.id} className={exerciseCardClassName(exercise, user?.id)}>
|
||||
<div className="exercise-card-layout exercise-card-layout--grow">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(Number(exercise.id))}
|
||||
onChange={() => toggleSelect(exercise.id)}
|
||||
aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`}
|
||||
className="exercise-card-layout__check"
|
||||
/>
|
||||
<div className="exercise-card__body exercise-card-body-flex">
|
||||
<h3 className="exercise-card-title">
|
||||
<Link to={`/exercises/${exercise.id}`}>
|
||||
{exercise.title}
|
||||
</Link>
|
||||
</h3>
|
||||
<div className="exercise-card-tags">
|
||||
{focusNames.map((name) => (
|
||||
<span key={`fa:${name}`} className="exercise-tag exercise-tag--accent">{name}</span>
|
||||
))}
|
||||
{styleNames.map((name) => (
|
||||
<span key={`sd:${name}`} className="exercise-tag exercise-tag--style">{name}</span>
|
||||
))}
|
||||
{typeNames.map((name) => (
|
||||
<span key={`tt:${name}`} className="exercise-tag exercise-tag--training">{name}</span>
|
||||
))}
|
||||
<span className="exercise-tag exercise-tag--scope">{visibilityLabel(exercise.visibility)}</span>
|
||||
<span className="exercise-tag exercise-tag--meta">{statusLabel(exercise.status)}</span>
|
||||
</div>
|
||||
{summaryHtml ? (
|
||||
<div
|
||||
className="exercise-card-summary exercise-card-summary--rich"
|
||||
dangerouslySetInnerHTML={{ __html: summaryHtml }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="exercise-card__actions exercise-card__actions--icons">
|
||||
<Link
|
||||
to={`/exercises/${exercise.id}`}
|
||||
className="exercise-card__icon-btn"
|
||||
title="Ansehen"
|
||||
aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ ansehen`}
|
||||
>
|
||||
<Eye size={18} strokeWidth={2} aria-hidden />
|
||||
</Link>
|
||||
</h3>
|
||||
<div className="exercise-card-tags">
|
||||
{exercise.focus_area && (
|
||||
<span className="exercise-tag exercise-tag--accent">{exercise.focus_area}</span>
|
||||
)}
|
||||
<span className="exercise-tag">{exercise.visibility}</span>
|
||||
<span className="exercise-tag">{exercise.status}</span>
|
||||
</div>
|
||||
{exercise.summary && (
|
||||
<p className="exercise-card-summary">
|
||||
{exercise.summary.length > 160
|
||||
? `${exercise.summary.slice(0, 160)}…`
|
||||
: exercise.summary}
|
||||
</p>
|
||||
)}
|
||||
<Link
|
||||
to={`/exercises/${exercise.id}/edit`}
|
||||
className="exercise-card__icon-btn"
|
||||
title="Bearbeiten"
|
||||
aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ bearbeiten`}
|
||||
>
|
||||
<Pencil size={18} strokeWidth={2} aria-hidden />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="exercise-card__icon-btn exercise-card__icon-btn--danger"
|
||||
title="Löschen"
|
||||
aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ löschen`}
|
||||
onClick={() => handleDelete(exercise)}
|
||||
>
|
||||
<Trash2 size={18} strokeWidth={2} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="exercise-card__actions">
|
||||
<Link to={`/exercises/${exercise.id}`} className="btn btn-secondary">
|
||||
Ansehen
|
||||
</Link>
|
||||
<Link to={`/exercises/${exercise.id}/edit`} className="btn btn-secondary">
|
||||
Bearbeiten
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger btn-small"
|
||||
onClick={() => handleDelete(exercise)}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="exercises-load-more">
|
||||
|
|
|
|||
|
|
@ -146,28 +146,29 @@ function SkillsPage() {
|
|||
<div className="app-page skills-page">
|
||||
<h1 className="page-title">Fähigkeiten & Methoden</h1>
|
||||
|
||||
<div className="skills-page__tabs-scroll">
|
||||
<div
|
||||
className="planning-segment-group planning-segment-group--equal skills-page-mode-switch"
|
||||
role="tablist"
|
||||
aria-label="Bereich wählen"
|
||||
<div className="skills-page__tabs-scroll admin-page-subtabs" role="tablist" aria-label="Bereich wählen">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'skills'}
|
||||
className={
|
||||
'admin-page-subtabs__btn' + (activeTab === 'skills' ? ' admin-page-subtabs__btn--active' : '')
|
||||
}
|
||||
onClick={() => setActiveTab('skills')}
|
||||
>
|
||||
{['skills', 'methods'].map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab}
|
||||
className={
|
||||
'planning-segment-group__btn' +
|
||||
(activeTab === tab ? ' planning-segment-group__btn--active' : '')
|
||||
}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
>
|
||||
{tab === 'skills' ? 'Fähigkeiten' : 'Trainingsmethoden'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
Fähigkeiten
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'methods'}
|
||||
className={
|
||||
'admin-page-subtabs__btn' + (activeTab === 'methods' ? ' admin-page-subtabs__btn--active' : '')
|
||||
}
|
||||
onClick={() => setActiveTab('methods')}
|
||||
>
|
||||
Trainingsmethoden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Skills Tab */}
|
||||
|
|
|
|||
52
frontend/src/utils/sanitizeHtml.js
Normal file
52
frontend/src/utils/sanitizeHtml.js
Normal file
|
|
@ -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 []
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user