UX - Filter #12
|
|
@ -26,6 +26,24 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["exercises"])
|
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
|
# Kanonische Fähigkeitsstufen 1–5 (Übung ↔ Skill-Zeile), siehe Migration 029
|
||||||
_CANONICAL_SKILL_LEVELS = frozenset(
|
_CANONICAL_SKILL_LEVELS = frozenset(
|
||||||
{"basis", "grundlagen", "aufbau", "fortgeschritten", "optimierung"}
|
{"basis", "grundlagen", "aufbau", "fortgeschritten", "optimierung"}
|
||||||
|
|
@ -971,7 +989,34 @@ def list_exercises(
|
||||||
WHERE efa.exercise_id = e.id
|
WHERE efa.exercise_id = e.id
|
||||||
ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
|
ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
|
||||||
LIMIT 1
|
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}
|
{variants_sql}
|
||||||
FROM exercises e
|
FROM exercises e
|
||||||
LEFT JOIN profiles p ON e.created_by = p.id
|
LEFT JOIN profiles p ON e.created_by = p.id
|
||||||
|
|
@ -990,6 +1035,9 @@ def list_exercises(
|
||||||
d = r2d(r)
|
d = r2d(r)
|
||||||
pfn = d.get("primary_focus_name")
|
pfn = d.get("primary_focus_name")
|
||||||
d["focus_area"] = pfn
|
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:
|
if include_variants:
|
||||||
v = d.get("variants")
|
v = d.get("variants")
|
||||||
if isinstance(v, str):
|
if isinstance(v, str):
|
||||||
|
|
|
||||||
|
|
@ -1442,12 +1442,6 @@ button.capture-shell__nav-item {
|
||||||
.skills-page__tabs-scroll::-webkit-scrollbar {
|
.skills-page__tabs-scroll::-webkit-scrollbar {
|
||||||
display: none;
|
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) */
|
/* Trainingsplanung: kompakte Segmente (Gruppe / Verein) */
|
||||||
|
|
@ -2463,6 +2457,12 @@ button.capture-shell__nav-item {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clubs-page__intro {
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
max-width: 46rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
/* Übungsliste: Kopf, Modus-Segmente, Hinweise */
|
/* Übungsliste: Kopf, Modus-Segmente, Hinweise */
|
||||||
.exercises-page__header {
|
.exercises-page__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -2478,11 +2478,6 @@ button.capture-shell__nav-item {
|
||||||
.exercises-page-toolbar-tabs {
|
.exercises-page-toolbar-tabs {
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
.exercises-page-mode-switch,
|
|
||||||
.skills-page-mode-switch {
|
|
||||||
width: 100%;
|
|
||||||
max-width: min(100%, 28rem);
|
|
||||||
}
|
|
||||||
.exercise-search-hint {
|
.exercise-search-hint {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text3);
|
color: var(--text3);
|
||||||
|
|
@ -2515,14 +2510,24 @@ button.capture-shell__nav-item {
|
||||||
}
|
}
|
||||||
.exercises-list-grid {
|
.exercises-list-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr));
|
||||||
gap: 12px;
|
gap: 14px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.exercises-list-grid > .exercise-card {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.exercise-card-layout {
|
.exercise-card-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
.exercise-card-layout--grow {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
.exercise-card-layout__check {
|
.exercise-card-layout__check {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
@ -2562,6 +2567,28 @@ button.capture-shell__nav-item {
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
margin: 0;
|
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 {
|
.exercises-meta-line {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text2);
|
color: var(--text2);
|
||||||
|
|
@ -3665,7 +3692,31 @@ button.capture-shell__nav-item {
|
||||||
.exercise-card {
|
.exercise-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.exercise-card__body {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
|
@ -3714,10 +3765,51 @@ button.capture-shell__nav-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-top: 12px;
|
margin-top: auto;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
border-top: 1px solid var(--border);
|
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 .btn,
|
||||||
.exercise-card__actions a.btn {
|
.exercise-card__actions a.btn {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
|
@ -3747,6 +3839,28 @@ button.capture-shell__nav-item {
|
||||||
color: var(--accent-dark);
|
color: var(--accent-dark);
|
||||||
border-color: transparent;
|
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 {
|
.exercise-detail-shell {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,14 @@ export default function AdminMaturityModelsPage() {
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={tab === 'catalog'}
|
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')}
|
onClick={() => setTab('catalog')}
|
||||||
>
|
>
|
||||||
Katalog und Hierarchie
|
Katalog und Hierarchie
|
||||||
|
|
@ -41,7 +43,9 @@ export default function AdminMaturityModelsPage() {
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={tab === 'models'}
|
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')}
|
onClick={() => setTab('models')}
|
||||||
>
|
>
|
||||||
Reifegradmodelle
|
Reifegradmodelle
|
||||||
|
|
@ -50,7 +54,9 @@ export default function AdminMaturityModelsPage() {
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={tab === 'bindings'}
|
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')}
|
onClick={() => setTab('bindings')}
|
||||||
>
|
>
|
||||||
Kontext-Zuordnung
|
Kontext-Zuordnung
|
||||||
|
|
@ -59,7 +65,9 @@ export default function AdminMaturityModelsPage() {
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={tab === 'matrixviz'}
|
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')}
|
onClick={() => setTab('matrixviz')}
|
||||||
>
|
>
|
||||||
Matrix-Ansicht und Export
|
Matrix-Ansicht und Export
|
||||||
|
|
|
||||||
|
|
@ -287,44 +287,36 @@ function ClubsPage() {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
<div className="skills-page__loading">
|
||||||
<div className="spinner"></div>
|
<div className="spinner"></div>
|
||||||
<p>Laden...</p>
|
<p>Laden...</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clubTabIds = canManageOrgSomewhere
|
||||||
|
? ['clubs', 'divisions', 'groups', 'members']
|
||||||
|
: ['clubs', 'divisions', 'groups']
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-page">
|
<div className="app-page clubs-page">
|
||||||
<h1 style={{ marginBottom: '0.75rem' }}>Vereinsverwaltung</h1>
|
<h1 className="page-title">Vereinsverwaltung</h1>
|
||||||
<p style={{ color: 'var(--text2)', marginBottom: '1.35rem', maxWidth: '46rem', lineHeight: 1.55 }}>
|
<p className="clubs-page__intro muted">
|
||||||
Für die Trainingsplanung wird mindestens ein <strong>Verein</strong> und eine <strong>Trainingsgruppe</strong> gebraucht.
|
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.
|
Sparten sind optional — typische Eckdaten einer Gruppe (Wochentag, Zeit, Ort) kannst du schrittweise eintragen.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Tabs */}
|
<div className="admin-page-subtabs" role="tablist" aria-label="Vereinsverwaltung">
|
||||||
<div style={{
|
{clubTabIds.map((tab) => (
|
||||||
display: 'flex',
|
|
||||||
gap: '0.5rem',
|
|
||||||
marginBottom: '1.5rem',
|
|
||||||
borderBottom: '2px solid var(--border)'
|
|
||||||
}}>
|
|
||||||
{(canManageOrgSomewhere
|
|
||||||
? ['clubs', 'divisions', 'groups', 'members']
|
|
||||||
: ['clubs', 'divisions', 'groups']
|
|
||||||
).map(tab => (
|
|
||||||
<button
|
<button
|
||||||
key={tab}
|
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)}
|
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 === 'clubs' && 'Vereine'}
|
||||||
{tab === 'divisions' && 'Sparten'}
|
{tab === 'divisions' && 'Sparten'}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
import { Eye, Pencil, Trash2 } from 'lucide-react'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
|
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
|
||||||
import MultiSelectCombo from '../components/MultiSelectCombo'
|
import MultiSelectCombo from '../components/MultiSelectCombo'
|
||||||
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
|
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
|
||||||
|
import { sanitizeExerciseRichText, coerceApiNameList } from '../utils/sanitizeHtml'
|
||||||
|
|
||||||
const PAGE_SIZE = 100
|
const PAGE_SIZE = 100
|
||||||
const BULK_MAX_IDS = 500
|
const BULK_MAX_IDS = 500
|
||||||
|
|
@ -22,6 +24,38 @@ const INITIAL_FILTERS = {
|
||||||
status_any: [],
|
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) {
|
function levelOptionShort(levelStr) {
|
||||||
const o = LEVEL_FILTER_OPTS.find((x) => String(x.level) === String(levelStr))
|
const o = LEVEL_FILTER_OPTS.find((x) => String(x.level) === String(levelStr))
|
||||||
return o ? String(o.level) : String(levelStr)
|
return o ? String(o.level) : String(levelStr)
|
||||||
|
|
@ -569,15 +603,13 @@ function ExercisesListPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="exercises-page-toolbar-tabs" role="tablist" aria-label="Übungen Bereiche">
|
<div className="exercises-page-toolbar-tabs admin-page-subtabs" role="tablist" aria-label="Übungen Bereiche">
|
||||||
<div className="planning-segment-group planning-segment-group--equal exercises-page-mode-switch">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={pageTab === 'list'}
|
aria-selected={pageTab === 'list'}
|
||||||
className={
|
className={
|
||||||
'planning-segment-group__btn' +
|
'admin-page-subtabs__btn' + (pageTab === 'list' ? ' admin-page-subtabs__btn--active' : '')
|
||||||
(pageTab === 'list' ? ' planning-segment-group__btn--active' : '')
|
|
||||||
}
|
}
|
||||||
onClick={() => setPageTab('list')}
|
onClick={() => setPageTab('list')}
|
||||||
>
|
>
|
||||||
|
|
@ -588,15 +620,14 @@ function ExercisesListPage() {
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={pageTab === 'progression'}
|
aria-selected={pageTab === 'progression'}
|
||||||
className={
|
className={
|
||||||
'planning-segment-group__btn' +
|
'admin-page-subtabs__btn' +
|
||||||
(pageTab === 'progression' ? ' planning-segment-group__btn--active' : '')
|
(pageTab === 'progression' ? ' admin-page-subtabs__btn--active' : '')
|
||||||
}
|
}
|
||||||
onClick={() => setPageTab('progression')}
|
onClick={() => setPageTab('progression')}
|
||||||
>
|
>
|
||||||
Progressionsgraphen
|
Progressionsgraphen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{pageTab === 'progression' ? (
|
{pageTab === 'progression' ? (
|
||||||
<ExerciseProgressionGraphPanel />
|
<ExerciseProgressionGraphPanel />
|
||||||
|
|
@ -1093,9 +1124,16 @@ function ExercisesListPage() {
|
||||||
{hasMore ? ' · es gibt weitere Einträge' : ''}
|
{hasMore ? ' · es gibt weitere Einträge' : ''}
|
||||||
</p>
|
</p>
|
||||||
<div className="exercises-list-grid">
|
<div className="exercises-list-grid">
|
||||||
{exercises.map((exercise) => (
|
{exercises.map((exercise) => {
|
||||||
<div key={exercise.id} className="card exercise-card">
|
const focusNames = exerciseFocusNames(exercise)
|
||||||
<div className="exercise-card-layout">
|
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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedIds.has(Number(exercise.id))}
|
checked={selectedIds.has(Number(exercise.id))}
|
||||||
|
|
@ -1110,38 +1148,56 @@ function ExercisesListPage() {
|
||||||
</Link>
|
</Link>
|
||||||
</h3>
|
</h3>
|
||||||
<div className="exercise-card-tags">
|
<div className="exercise-card-tags">
|
||||||
{exercise.focus_area && (
|
{focusNames.map((name) => (
|
||||||
<span className="exercise-tag exercise-tag--accent">{exercise.focus_area}</span>
|
<span key={`fa:${name}`} className="exercise-tag exercise-tag--accent">{name}</span>
|
||||||
)}
|
))}
|
||||||
<span className="exercise-tag">{exercise.visibility}</span>
|
{styleNames.map((name) => (
|
||||||
<span className="exercise-tag">{exercise.status}</span>
|
<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>
|
</div>
|
||||||
{exercise.summary && (
|
{summaryHtml ? (
|
||||||
<p className="exercise-card-summary">
|
<div
|
||||||
{exercise.summary.length > 160
|
className="exercise-card-summary exercise-card-summary--rich"
|
||||||
? `${exercise.summary.slice(0, 160)}…`
|
dangerouslySetInnerHTML={{ __html: summaryHtml }}
|
||||||
: exercise.summary}
|
/>
|
||||||
</p>
|
) : null}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="exercise-card__actions">
|
<div className="exercise-card__actions exercise-card__actions--icons">
|
||||||
<Link to={`/exercises/${exercise.id}`} className="btn btn-secondary">
|
<Link
|
||||||
Ansehen
|
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>
|
</Link>
|
||||||
<Link to={`/exercises/${exercise.id}/edit`} className="btn btn-secondary">
|
<Link
|
||||||
Bearbeiten
|
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>
|
</Link>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-danger btn-small"
|
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)}
|
onClick={() => handleDelete(exercise)}
|
||||||
>
|
>
|
||||||
Löschen
|
<Trash2 size={18} strokeWidth={2} aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div className="exercises-load-more">
|
<div className="exercises-load-more">
|
||||||
|
|
|
||||||
|
|
@ -146,28 +146,29 @@ function SkillsPage() {
|
||||||
<div className="app-page skills-page">
|
<div className="app-page skills-page">
|
||||||
<h1 className="page-title">Fähigkeiten & Methoden</h1>
|
<h1 className="page-title">Fähigkeiten & Methoden</h1>
|
||||||
|
|
||||||
<div className="skills-page__tabs-scroll">
|
<div className="skills-page__tabs-scroll admin-page-subtabs" role="tablist" aria-label="Bereich wählen">
|
||||||
<div
|
|
||||||
className="planning-segment-group planning-segment-group--equal skills-page-mode-switch"
|
|
||||||
role="tablist"
|
|
||||||
aria-label="Bereich wählen"
|
|
||||||
>
|
|
||||||
{['skills', 'methods'].map(tab => (
|
|
||||||
<button
|
<button
|
||||||
key={tab}
|
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={activeTab === tab}
|
aria-selected={activeTab === 'skills'}
|
||||||
className={
|
className={
|
||||||
'planning-segment-group__btn' +
|
'admin-page-subtabs__btn' + (activeTab === 'skills' ? ' admin-page-subtabs__btn--active' : '')
|
||||||
(activeTab === tab ? ' planning-segment-group__btn--active' : '')
|
|
||||||
}
|
}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab('skills')}
|
||||||
>
|
>
|
||||||
{tab === 'skills' ? 'Fähigkeiten' : 'Trainingsmethoden'}
|
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>
|
</button>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Skills Tab */}
|
{/* 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