feat: enhance exercise management with training types and rich text support
- Added support for training types in exercise creation and updates, allowing for better categorization of exercises. - Implemented a rich text editor for exercise descriptions, improving content formatting capabilities. - Updated the ExerciseDetailPage to display training types and enhanced the layout for better user experience. - Refactored ExerciseFormPage to accommodate new multi-association fields for training styles, types, and target groups. - Improved API payload handling to include training types and ensure proper data structure for exercise management. - Enhanced the ExercisesListPage with improved loading and filtering functionalities for better performance.
This commit is contained in:
parent
cb11e39201
commit
d8f439a3e5
|
|
@ -54,6 +54,7 @@ class ExerciseCreate(BaseModel):
|
||||||
# M:N Relations (Liste von {id: int, is_primary: bool})
|
# M:N Relations (Liste von {id: int, is_primary: bool})
|
||||||
focus_areas_multi: list[dict] = []
|
focus_areas_multi: list[dict] = []
|
||||||
training_styles_multi: list[dict] = []
|
training_styles_multi: list[dict] = []
|
||||||
|
training_types_multi: list[dict] = []
|
||||||
target_groups_multi: list[dict] = []
|
target_groups_multi: list[dict] = []
|
||||||
age_groups: list[str] = [] # ["Kinder", "Teenager"] aus Katalog
|
age_groups: list[str] = [] # ["Kinder", "Teenager"] aus Katalog
|
||||||
|
|
||||||
|
|
@ -91,6 +92,7 @@ class ExerciseUpdate(BaseModel):
|
||||||
equipment: Optional[list[str]] = None
|
equipment: Optional[list[str]] = None
|
||||||
focus_areas_multi: Optional[list[dict]] = None
|
focus_areas_multi: Optional[list[dict]] = None
|
||||||
training_styles_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
|
target_groups_multi: Optional[list[dict]] = None
|
||||||
age_groups: Optional[list[str]] = None
|
age_groups: Optional[list[str]] = None
|
||||||
skills: Optional[list[dict]] = 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()]
|
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)
|
# Target Groups (M:N)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""SELECT etg.id, etg.target_group_id, tg.name, tg.description, etg.is_primary
|
"""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))
|
(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:
|
if "training_styles_multi" in data:
|
||||||
cur.execute("DELETE FROM exercise_style_directions WHERE exercise_id = %s", (exercise_id,))
|
cur.execute("DELETE FROM exercise_style_directions WHERE exercise_id = %s", (exercise_id,))
|
||||||
for ts in data["training_styles_multi"]:
|
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))
|
(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
|
# Target Groups
|
||||||
if "target_groups_multi" in data:
|
if "target_groups_multi" in data:
|
||||||
cur.execute("DELETE FROM exercise_target_groups WHERE exercise_id = %s", (exercise_id,))
|
cur.execute("DELETE FROM exercise_target_groups WHERE exercise_id = %s", (exercise_id,))
|
||||||
|
|
|
||||||
|
|
@ -2217,3 +2217,194 @@ a.analysis-split__nav-item {
|
||||||
gap: 10px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
93
frontend/src/components/RichTextEditor.jsx
Normal file
93
frontend/src/components/RichTextEditor.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="rich-text-editor-wrap">
|
||||||
|
<div className="rich-text-toolbar" role="toolbar" aria-label="Formatierung">
|
||||||
|
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('bold')}>
|
||||||
|
<strong>B</strong>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('italic')}>
|
||||||
|
<em>I</em>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('underline')}>
|
||||||
|
U
|
||||||
|
</button>
|
||||||
|
<span className="rte-sep" />
|
||||||
|
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('formatBlock', 'h3')}>
|
||||||
|
H3
|
||||||
|
</button>
|
||||||
|
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('insertUnorderedList')}>
|
||||||
|
•
|
||||||
|
</button>
|
||||||
|
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('insertOrderedList')}>
|
||||||
|
1.
|
||||||
|
</button>
|
||||||
|
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('formatBlock', 'p')}>
|
||||||
|
¶
|
||||||
|
</button>
|
||||||
|
<span className="rte-sep" />
|
||||||
|
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={onLink}>
|
||||||
|
Link
|
||||||
|
</button>
|
||||||
|
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('removeFormat')}>
|
||||||
|
⌫
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="rich-text-editor"
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning
|
||||||
|
data-placeholder={placeholder || ''}
|
||||||
|
style={{ minHeight }}
|
||||||
|
onFocus={() => setFocused(true)}
|
||||||
|
onBlur={() => {
|
||||||
|
setFocused(false)
|
||||||
|
sync()
|
||||||
|
}}
|
||||||
|
onInput={sync}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
|
import { sanitizeTrainerHtml } from '../utils/htmlUtils'
|
||||||
|
|
||||||
const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
|
const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
|
||||||
|
|
||||||
|
|
@ -11,6 +12,17 @@ function resolveMediaUrl(filePath) {
|
||||||
return `${API_BASE}${p}`
|
return `${API_BASE}${p}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function HtmlBlock({ html, className = '' }) {
|
||||||
|
if (!html || !String(html).trim()) return null
|
||||||
|
const safe = sanitizeTrainerHtml(html)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rich-text-content ${className}`}
|
||||||
|
dangerouslySetInnerHTML={{ __html: safe }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function MediaBlock({ media }) {
|
function MediaBlock({ media }) {
|
||||||
if (media.embed_url) {
|
if (media.embed_url) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -47,6 +59,54 @@ function MediaBlock({ media }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TagRow({ exercise }) {
|
||||||
|
const tags = []
|
||||||
|
;(exercise.focus_areas || []).forEach((f) => {
|
||||||
|
tags.push({ key: `fa-${f.id}`, label: f.name, accent: !!f.is_primary })
|
||||||
|
})
|
||||||
|
;(exercise.training_styles || []).forEach((t) => {
|
||||||
|
tags.push({ key: `ts-${t.id}`, label: t.name, accent: false })
|
||||||
|
})
|
||||||
|
;(exercise.training_types || []).forEach((t) => {
|
||||||
|
tags.push({ key: `tt-${t.id}`, label: t.name, accent: false })
|
||||||
|
})
|
||||||
|
;(exercise.target_groups || []).forEach((g) => {
|
||||||
|
tags.push({ key: `tg-${g.id}`, label: g.name, accent: !!g.is_primary })
|
||||||
|
})
|
||||||
|
;(exercise.age_groups || []).forEach((ag) => {
|
||||||
|
tags.push({ key: `ag-${ag}`, label: ag, accent: false })
|
||||||
|
})
|
||||||
|
if (tags.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="exercise-tag-row">
|
||||||
|
{tags.map((t) => (
|
||||||
|
<span key={t.key} className={`exercise-tag${t.accent ? ' exercise-tag--accent' : ''}`}>
|
||||||
|
{t.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function metaParts(exercise) {
|
||||||
|
const parts = []
|
||||||
|
if (exercise.duration_min != null || exercise.duration_max != null) {
|
||||||
|
const a = exercise.duration_min
|
||||||
|
const b = exercise.duration_max
|
||||||
|
if (a != null && b != null && a !== b) parts.push(`${a}–${b} Min.`)
|
||||||
|
else if (a != null) parts.push(`ca. ${a} Min.`)
|
||||||
|
else if (b != null) parts.push(`ca. ${b} Min.`)
|
||||||
|
}
|
||||||
|
if (exercise.group_size_min != null || exercise.group_size_max != null) {
|
||||||
|
const a = exercise.group_size_min
|
||||||
|
const b = exercise.group_size_max
|
||||||
|
if (a != null && b != null && a !== b) parts.push(`Gruppe ${a}–${b}`)
|
||||||
|
else if (a != null) parts.push(`Gruppe ab ${a}`)
|
||||||
|
else if (b != null) parts.push(`Gruppe bis ${b}`)
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
function ExerciseDetailPage() {
|
function ExerciseDetailPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -86,7 +146,7 @@ function ExerciseDetailPage() {
|
||||||
if (error) {
|
if (error) {
|
||||||
const msg = error.message || String(error)
|
const msg = error.message || String(error)
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2rem', maxWidth: '720px', margin: '0 auto' }}>
|
<div style={{ padding: '1rem', maxWidth: '640px', margin: '0 auto' }}>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2>Übung</h2>
|
<h2>Übung</h2>
|
||||||
<p style={{ color: 'var(--danger)' }}>{msg}</p>
|
<p style={{ color: 'var(--danger)' }}>{msg}</p>
|
||||||
|
|
@ -100,147 +160,127 @@ function ExerciseDetailPage() {
|
||||||
|
|
||||||
if (!exercise) return null
|
if (!exercise) return null
|
||||||
|
|
||||||
const chips = (items, labelKey = 'name') =>
|
const meta = metaParts(exercise)
|
||||||
(items || []).length ? (items || []).map((x) => x[labelKey]).join(', ') : '—'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2rem' }}>
|
<div className="exercise-detail-shell" style={{ padding: '12px 12px 24px' }}>
|
||||||
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
|
<div style={{ marginBottom: '12px', display: 'flex', justifyContent: 'space-between', gap: '8px', flexWrap: 'wrap' }}>
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
<button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}>
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => navigate('/exercises')}>
|
← Übersicht
|
||||||
← Übersicht
|
</button>
|
||||||
</button>
|
<Link to={`/exercises/${exercise.id}/edit`} className="btn btn-primary">
|
||||||
</div>
|
Bearbeiten
|
||||||
|
</Link>
|
||||||
<div className="card" style={{ marginBottom: '1rem' }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
gap: '1rem',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h1 style={{ margin: 0 }}>{exercise.title}</h1>
|
|
||||||
<Link to={`/exercises/${exercise.id}/edit`} className="btn btn-primary">
|
|
||||||
Bearbeiten
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
{exercise.summary && (
|
|
||||||
<p style={{ color: 'var(--text2)', marginTop: '1rem' }}>{exercise.summary}</p>
|
|
||||||
)}
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '1rem' }}>
|
|
||||||
<span className="badge">{exercise.visibility}</span>
|
|
||||||
<span className="badge">{exercise.status}</span>
|
|
||||||
{exercise.club_name && <span className="badge">{exercise.club_name}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="card" style={{ marginBottom: '1rem' }}>
|
|
||||||
<h2 style={{ marginTop: 0 }}>Zuordnung</h2>
|
|
||||||
<p>
|
|
||||||
<strong>Fokusbereiche:</strong> {chips(exercise.focus_areas)}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Stilrichtungen:</strong> {chips(exercise.training_styles)}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Zielgruppen:</strong> {chips(exercise.target_groups)}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Altersgruppen:</strong>{' '}
|
|
||||||
{(exercise.age_groups || []).length ? exercise.age_groups.join(', ') : '—'}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{exercise.goal && (
|
|
||||||
<section className="card" style={{ marginBottom: '1rem' }}>
|
|
||||||
<h2 style={{ marginTop: 0 }}>Ziel</h2>
|
|
||||||
<p style={{ whiteSpace: 'pre-wrap' }}>{exercise.goal}</p>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{exercise.execution && (
|
|
||||||
<section className="card" style={{ marginBottom: '1rem' }}>
|
|
||||||
<h2 style={{ marginTop: 0 }}>Durchführung</h2>
|
|
||||||
<p style={{ whiteSpace: 'pre-wrap' }}>{exercise.execution}</p>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(exercise.preparation || exercise.trainer_notes) && (
|
|
||||||
<section className="card" style={{ marginBottom: '1rem' }}>
|
|
||||||
<h2 style={{ marginTop: 0 }}>Trainer</h2>
|
|
||||||
{exercise.preparation && (
|
|
||||||
<>
|
|
||||||
<h3 style={{ fontSize: '1rem' }}>Vorbereitung</h3>
|
|
||||||
<p style={{ whiteSpace: 'pre-wrap' }}>{exercise.preparation}</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{exercise.trainer_notes && (
|
|
||||||
<>
|
|
||||||
<h3 style={{ fontSize: '1rem' }}>Hinweise</h3>
|
|
||||||
<p style={{ whiteSpace: 'pre-wrap' }}>{exercise.trainer_notes}</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{exercise.equipment && exercise.equipment.length > 0 && (
|
|
||||||
<section className="card" style={{ marginBottom: '1rem' }}>
|
|
||||||
<h2 style={{ marginTop: 0 }}>Material</h2>
|
|
||||||
<ul>
|
|
||||||
{exercise.equipment.map((x, i) => (
|
|
||||||
<li key={i}>{x}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(exercise.skills || []).length > 0 && (
|
|
||||||
<section className="card" style={{ marginBottom: '1rem' }}>
|
|
||||||
<h2 style={{ marginTop: 0 }}>Fähigkeiten</h2>
|
|
||||||
<ul style={{ paddingLeft: '1.25rem' }}>
|
|
||||||
{exercise.skills.map((s) => (
|
|
||||||
<li key={s.id}>
|
|
||||||
{s.skill_name}
|
|
||||||
{s.skill_category ? ` (${s.skill_category})` : ''}
|
|
||||||
{s.is_primary ? ' · primär' : ''}
|
|
||||||
{s.intensity ? ` · ${s.intensity}` : ''}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(exercise.variants || []).length > 0 && (
|
|
||||||
<section className="card" style={{ marginBottom: '1rem' }}>
|
|
||||||
<h2 style={{ marginTop: 0 }}>Varianten</h2>
|
|
||||||
{exercise.variants.map((v) => (
|
|
||||||
<div key={v.id} style={{ marginBottom: '1rem', paddingBottom: '1rem', borderBottom: '1px solid var(--border)' }}>
|
|
||||||
<strong>{v.variant_name}</strong>
|
|
||||||
{v.description && <p style={{ color: 'var(--text2)' }}>{v.description}</p>}
|
|
||||||
{v.execution_changes && (
|
|
||||||
<p style={{ whiteSpace: 'pre-wrap' }}>{v.execution_changes}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(exercise.media || []).length > 0 && (
|
|
||||||
<section className="card">
|
|
||||||
<h2 style={{ marginTop: 0 }}>Medien</h2>
|
|
||||||
{exercise.media.map((m) => (
|
|
||||||
<div key={m.id} style={{ marginBottom: '1.5rem' }}>
|
|
||||||
<strong>{m.title || m.original_filename || m.media_type}</strong>
|
|
||||||
{m.description && <p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{m.description}</p>}
|
|
||||||
<MediaBlock media={m} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="card exercise-detail-section">
|
||||||
|
<h1 style={{ margin: 0, fontSize: '1.35rem', lineHeight: 1.25 }}>{exercise.title}</h1>
|
||||||
|
{exercise.summary && (
|
||||||
|
<div style={{ marginTop: '10px', color: 'var(--text2)', fontSize: '15px' }}>
|
||||||
|
<HtmlBlock html={exercise.summary} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<TagRow exercise={exercise} />
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginTop: '10px' }}>
|
||||||
|
<span className="badge">{exercise.visibility}</span>
|
||||||
|
<span className="badge">{exercise.status}</span>
|
||||||
|
{exercise.club_name && <span className="badge">{exercise.club_name}</span>}
|
||||||
|
</div>
|
||||||
|
{meta.length > 0 && <p className="exercise-meta-line">{meta.join(' · ')}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{exercise.goal && (
|
||||||
|
<section className="card exercise-detail-section">
|
||||||
|
<h2>Ziel</h2>
|
||||||
|
<HtmlBlock html={exercise.goal} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(exercise.equipment || []).length > 0 && (
|
||||||
|
<section className="card exercise-detail-section">
|
||||||
|
<h2>Material & Aufbau</h2>
|
||||||
|
<ul style={{ paddingLeft: '1.2rem', margin: 0 }}>
|
||||||
|
{exercise.equipment.map((x, i) => (
|
||||||
|
<li key={i}>{x}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{exercise.preparation && (
|
||||||
|
<section className="card exercise-detail-section">
|
||||||
|
<h2>Vorbereitung</h2>
|
||||||
|
<HtmlBlock html={exercise.preparation} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{exercise.execution && (
|
||||||
|
<section className="card exercise-detail-section">
|
||||||
|
<h2>Ablauf</h2>
|
||||||
|
<HtmlBlock html={exercise.execution} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(exercise.media || []).length > 0 && (
|
||||||
|
<section className="card exercise-detail-section">
|
||||||
|
<h2>Medien</h2>
|
||||||
|
{exercise.media.map((m) => (
|
||||||
|
<div key={m.id} style={{ marginBottom: '1.25rem' }}>
|
||||||
|
<strong style={{ fontSize: '15px' }}>{m.title || m.original_filename || m.media_type}</strong>
|
||||||
|
{m.description && <p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{m.description}</p>}
|
||||||
|
<MediaBlock media={m} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{exercise.trainer_notes && (
|
||||||
|
<section className="card exercise-detail-section">
|
||||||
|
<h2>Hinweise für Trainer</h2>
|
||||||
|
<HtmlBlock html={exercise.trainer_notes} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(exercise.skills || []).length > 0 && (
|
||||||
|
<section className="card exercise-detail-section">
|
||||||
|
<h2>Fähigkeiten</h2>
|
||||||
|
<div className="exercise-tag-row">
|
||||||
|
{exercise.skills.map((s) => (
|
||||||
|
<span key={s.id} className={`exercise-tag${s.is_primary ? ' exercise-tag--accent' : ''}`}>
|
||||||
|
{s.skill_name}
|
||||||
|
{s.intensity ? ` · ${s.intensity}` : ''}
|
||||||
|
{s.required_level || s.target_level
|
||||||
|
? ` (${[s.required_level, s.target_level].filter(Boolean).join(' → ')})`
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(exercise.variants || []).length > 0 && (
|
||||||
|
<section className="card exercise-detail-section">
|
||||||
|
<h2>Varianten</h2>
|
||||||
|
{exercise.variants.map((v) => (
|
||||||
|
<div
|
||||||
|
key={v.id}
|
||||||
|
style={{
|
||||||
|
marginBottom: '1rem',
|
||||||
|
paddingBottom: '1rem',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong style={{ fontSize: '15px' }}>{v.variant_name}</strong>
|
||||||
|
{v.description && <p style={{ color: 'var(--text2)', marginTop: '4px' }}>{v.description}</p>}
|
||||||
|
{v.execution_changes && (
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<HtmlBlock html={v.execution_changes} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -2,10 +2,15 @@ import React, { useState, useEffect } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 100
|
||||||
|
|
||||||
function ExercisesListPage() {
|
function ExercisesListPage() {
|
||||||
const [exercises, setExercises] = useState([])
|
const [exercises, setExercises] = useState([])
|
||||||
const [focusAreas, setFocusAreas] = useState([])
|
const [focusAreas, setFocusAreas] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
|
const [offset, setOffset] = useState(0)
|
||||||
|
const [hasMore, setHasMore] = useState(false)
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
focus_area: '',
|
focus_area: '',
|
||||||
visibility: '',
|
visibility: '',
|
||||||
|
|
@ -13,22 +18,47 @@ function ExercisesListPage() {
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData()
|
let cancelled = false
|
||||||
|
const run = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setOffset(0)
|
||||||
|
try {
|
||||||
|
const [batch, focusAreasData] = await Promise.all([
|
||||||
|
api.listExercises({ ...filters, limit: PAGE_SIZE, offset: 0 }),
|
||||||
|
api.listFocusAreas(),
|
||||||
|
])
|
||||||
|
if (cancelled) return
|
||||||
|
setExercises(batch)
|
||||||
|
setFocusAreas(focusAreasData)
|
||||||
|
setHasMore(batch.length === PAGE_SIZE)
|
||||||
|
setOffset(batch.length)
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.error('Failed to load data:', err)
|
||||||
|
alert('Fehler beim Laden: ' + err.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
}, [filters])
|
}, [filters])
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadMore = async () => {
|
||||||
|
if (loadingMore || !hasMore) return
|
||||||
|
setLoadingMore(true)
|
||||||
try {
|
try {
|
||||||
const [exercisesData, focusAreasData] = await Promise.all([
|
const batch = await api.listExercises({ ...filters, limit: PAGE_SIZE, offset })
|
||||||
api.listExercises(filters),
|
setExercises((prev) => [...prev, ...batch])
|
||||||
api.listFocusAreas(),
|
setHasMore(batch.length === PAGE_SIZE)
|
||||||
])
|
setOffset((o) => o + batch.length)
|
||||||
setExercises(exercisesData)
|
|
||||||
setFocusAreas(focusAreasData)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load data:', err)
|
alert('Fehler: ' + err.message)
|
||||||
alert('Fehler beim Laden: ' + err.message)
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoadingMore(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,7 +66,7 @@ function ExercisesListPage() {
|
||||||
if (!confirm(`Übung "${exercise.title}" wirklich löschen?`)) return
|
if (!confirm(`Übung "${exercise.title}" wirklich löschen?`)) return
|
||||||
try {
|
try {
|
||||||
await api.deleteExercise(exercise.id)
|
await api.deleteExercise(exercise.id)
|
||||||
await loadData()
|
setExercises((prev) => prev.filter((e) => e.id !== exercise.id))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Fehler beim Löschen: ' + err.message)
|
alert('Fehler beim Löschen: ' + err.message)
|
||||||
}
|
}
|
||||||
|
|
@ -52,95 +82,91 @@ function ExercisesListPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2rem' }}>
|
<div style={{ padding: '12px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
<div
|
||||||
<div
|
style={{
|
||||||
style={{
|
display: 'flex',
|
||||||
display: 'flex',
|
justifyContent: 'space-between',
|
||||||
justifyContent: 'space-between',
|
alignItems: 'center',
|
||||||
alignItems: 'center',
|
marginBottom: '12px',
|
||||||
marginBottom: '1.5rem',
|
flexWrap: 'wrap',
|
||||||
flexWrap: 'wrap',
|
gap: '8px',
|
||||||
gap: '0.75rem',
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<h1 style={{ fontSize: '1.35rem' }}>Übungen</h1>
|
||||||
<h1>Übungen</h1>
|
<Link to="/exercises/new" className="btn btn-primary">
|
||||||
<Link to="/exercises/new" className="btn btn-primary">
|
+ Neu
|
||||||
+ Neue Übung
|
</Link>
|
||||||
</Link>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
<div className="card exercise-filters-compact" style={{ marginBottom: '12px' }}>
|
||||||
<div
|
<div>
|
||||||
style={{
|
<label className="form-label">Fokus</label>
|
||||||
display: 'grid',
|
<select
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
className="form-input"
|
||||||
gap: '1rem',
|
value={filters.focus_area}
|
||||||
}}
|
onChange={(e) => setFilters({ ...filters, focus_area: e.target.value })}
|
||||||
>
|
>
|
||||||
<div>
|
<option value="">Alle</option>
|
||||||
<label className="form-label">Fokusbereich</label>
|
{focusAreas.map((fa) => (
|
||||||
<select
|
<option key={fa.id} value={fa.id}>
|
||||||
className="form-input"
|
{fa.icon} {fa.name}
|
||||||
value={filters.focus_area}
|
</option>
|
||||||
onChange={(e) => setFilters({ ...filters, focus_area: e.target.value })}
|
))}
|
||||||
>
|
</select>
|
||||||
<option value="">Alle</option>
|
|
||||||
{focusAreas.map((fa) => (
|
|
||||||
<option key={fa.id} value={fa.id}>
|
|
||||||
{fa.icon} {fa.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="form-label">Sichtbarkeit</label>
|
|
||||||
<select
|
|
||||||
className="form-input"
|
|
||||||
value={filters.visibility}
|
|
||||||
onChange={(e) => setFilters({ ...filters, visibility: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="">Alle</option>
|
|
||||||
<option value="private">Privat</option>
|
|
||||||
<option value="club">Verein</option>
|
|
||||||
<option value="official">Offiziell</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="form-label">Status</label>
|
|
||||||
<select
|
|
||||||
className="form-input"
|
|
||||||
value={filters.status}
|
|
||||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="">Alle</option>
|
|
||||||
<option value="draft">Entwurf</option>
|
|
||||||
<option value="in_review">In Prüfung</option>
|
|
||||||
<option value="approved">Freigegeben</option>
|
|
||||||
<option value="archived">Archiviert</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Sichtbarkeit</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={filters.visibility}
|
||||||
|
onChange={(e) => setFilters({ ...filters, visibility: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
<option value="private">Privat</option>
|
||||||
|
<option value="club">Verein</option>
|
||||||
|
<option value="official">Offiziell</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Status</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={filters.status}
|
||||||
|
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
<option value="draft">Entwurf</option>
|
||||||
|
<option value="in_review">In Prüfung</option>
|
||||||
|
<option value="approved">Freigegeben</option>
|
||||||
|
<option value="archived">Archiviert</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{exercises.length === 0 ? (
|
{exercises.length === 0 ? (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
||||||
Keine Übungen gefunden. Lege jetzt deine erste Übung an!
|
Keine Übungen gefunden.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '10px' }}>
|
||||||
|
{exercises.length} angezeigt
|
||||||
|
{hasMore ? ' · es gibt weitere Einträge' : ''}
|
||||||
|
</p>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
|
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
||||||
gap: '1rem',
|
gap: '12px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{exercises.map((exercise) => (
|
{exercises.map((exercise) => (
|
||||||
<div key={exercise.id} className="card">
|
<div key={exercise.id} className="card exercise-card">
|
||||||
<div style={{ marginBottom: '1rem' }}>
|
<div className="exercise-card__body">
|
||||||
<h3 style={{ marginBottom: '0.5rem' }}>
|
<h3 style={{ marginBottom: '8px', fontSize: '1.05rem', lineHeight: 1.3 }}>
|
||||||
<Link
|
<Link
|
||||||
to={`/exercises/${exercise.id}`}
|
to={`/exercises/${exercise.id}`}
|
||||||
style={{ color: 'inherit', textDecoration: 'none' }}
|
style={{ color: 'inherit', textDecoration: 'none' }}
|
||||||
|
|
@ -148,68 +174,32 @@ function ExercisesListPage() {
|
||||||
{exercise.title}
|
{exercise.title}
|
||||||
</Link>
|
</Link>
|
||||||
</h3>
|
</h3>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', marginBottom: '8px' }}>
|
||||||
<span
|
{exercise.focus_area && (
|
||||||
style={{
|
<span className="exercise-tag exercise-tag--accent">{exercise.focus_area}</span>
|
||||||
fontSize: '0.75rem',
|
)}
|
||||||
padding: '0.25rem 0.5rem',
|
<span className="exercise-tag">{exercise.visibility}</span>
|
||||||
borderRadius: '4px',
|
<span className="exercise-tag">{exercise.status}</span>
|
||||||
background: 'var(--surface2)',
|
|
||||||
color: 'var(--text2)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{exercise.focus_area || 'Ohne Fokus'}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
padding: '0.25rem 0.5rem',
|
|
||||||
borderRadius: '4px',
|
|
||||||
background:
|
|
||||||
exercise.visibility === 'official' ? 'var(--accent)' : 'var(--surface2)',
|
|
||||||
color: exercise.visibility === 'official' ? 'white' : 'var(--text2)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{exercise.visibility}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
padding: '0.25rem 0.5rem',
|
|
||||||
borderRadius: '4px',
|
|
||||||
background: exercise.status === 'approved' ? '#2ea44f' : 'var(--surface2)',
|
|
||||||
color: exercise.status === 'approved' ? 'white' : 'var(--text2)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{exercise.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
{exercise.summary && (
|
||||||
|
<p style={{ color: 'var(--text2)', fontSize: '13px', lineHeight: 1.4 }}>
|
||||||
|
{exercise.summary.length > 160
|
||||||
|
? `${exercise.summary.slice(0, 160)}…`
|
||||||
|
: exercise.summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{exercise.summary && (
|
<div className="exercise-card__actions">
|
||||||
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem' }}>
|
<Link to={`/exercises/${exercise.id}`} className="btn btn-secondary">
|
||||||
{exercise.summary}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto', flexWrap: 'wrap' }}>
|
|
||||||
<Link
|
|
||||||
to={`/exercises/${exercise.id}`}
|
|
||||||
className="btn btn-secondary"
|
|
||||||
style={{ flex: '1 1 100px', textAlign: 'center' }}
|
|
||||||
>
|
|
||||||
Ansehen
|
Ansehen
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link to={`/exercises/${exercise.id}/edit`} className="btn btn-secondary">
|
||||||
to={`/exercises/${exercise.id}/edit`}
|
|
||||||
className="btn btn-secondary"
|
|
||||||
style={{ flex: '1 1 100px', textAlign: 'center' }}
|
|
||||||
>
|
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn"
|
className="btn"
|
||||||
style={{
|
style={{
|
||||||
flex: '1 1 100px',
|
|
||||||
background: 'var(--danger)',
|
background: 'var(--danger)',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
|
|
@ -222,8 +212,15 @@ function ExercisesListPage() {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{hasMore && (
|
||||||
</div>
|
<div style={{ textAlign: 'center', marginTop: '16px' }}>
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
|
||||||
|
{loadingMore ? 'Laden…' : 'Mehr laden'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
* Zentrale API-Kommunikation mit automatischer Token-Injektion
|
* Zentrale API-Kommunikation mit automatischer Token-Injektion
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { stripHtmlToText } from './htmlUtils'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || ''
|
const API_URL = import.meta.env.VITE_API_URL || ''
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -219,19 +221,37 @@ export async function listExercises(filters = {}) {
|
||||||
return request(`/api/exercises${query ? '?' + query : ''}`)
|
return request(`/api/exercises${query ? '?' + query : ''}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Formular → API-Body (M:N gemäß EXERCISES_API_SPEC) */
|
/** Formular → API-Body (M:N gemäß EXERCISES_API_SPEC + training_types) */
|
||||||
export function buildExerciseApiPayload(formData) {
|
export function buildExerciseApiPayload(formData) {
|
||||||
const num = (v) => (v === '' || v == null ? null : Number(v))
|
const num = (v) => (v === '' || v == null ? null : Number(v))
|
||||||
const goal = (formData.goal || '').trim()
|
|
||||||
const execution = (formData.execution || '').trim()
|
const goalHtml = formData.goal || ''
|
||||||
if (!goal && !execution) {
|
const execHtml = formData.execution || ''
|
||||||
throw new Error('Ziel oder Durchführung ausfüllen (mindestens eines).')
|
const goalText = stripHtmlToText(goalHtml)
|
||||||
|
const execText = stripHtmlToText(execHtml)
|
||||||
|
|
||||||
|
if (!goalText && !execText) {
|
||||||
|
throw new Error('Ziel oder Durchführung ausfüllen (mindestens eines, auch mit Formatierung).')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mapFocus = (formData.focus_areas_multi || [])
|
||||||
|
.filter((x) => x && x.focus_area_id)
|
||||||
|
.map((x) => ({ focus_area_id: Number(x.focus_area_id), is_primary: !!x.is_primary }))
|
||||||
|
const mapStyles = (formData.training_styles_multi || [])
|
||||||
|
.filter((x) => x && x.training_style_id)
|
||||||
|
.map((x) => ({ training_style_id: Number(x.training_style_id), is_primary: !!x.is_primary }))
|
||||||
|
const mapTTypes = (formData.training_types_multi || [])
|
||||||
|
.filter((x) => x && x.training_type_id)
|
||||||
|
.map((x) => ({ training_type_id: Number(x.training_type_id), is_primary: !!x.is_primary }))
|
||||||
|
const mapTg = (formData.target_groups_multi || [])
|
||||||
|
.filter((x) => x && x.target_group_id)
|
||||||
|
.map((x) => ({ target_group_id: Number(x.target_group_id), is_primary: !!x.is_primary }))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: (formData.title || '').trim(),
|
title: (formData.title || '').trim(),
|
||||||
summary: formData.summary || null,
|
summary: formData.summary || null,
|
||||||
goal: goal || null,
|
goal: goalHtml.trim() ? goalHtml : null,
|
||||||
execution: execution || null,
|
execution: execHtml.trim() ? execHtml : null,
|
||||||
preparation: formData.preparation || null,
|
preparation: formData.preparation || null,
|
||||||
trainer_notes: formData.trainer_notes || null,
|
trainer_notes: formData.trainer_notes || null,
|
||||||
duration_min: num(formData.duration_min),
|
duration_min: num(formData.duration_min),
|
||||||
|
|
@ -239,13 +259,10 @@ export function buildExerciseApiPayload(formData) {
|
||||||
group_size_min: num(formData.group_size_min),
|
group_size_min: num(formData.group_size_min),
|
||||||
group_size_max: num(formData.group_size_max),
|
group_size_max: num(formData.group_size_max),
|
||||||
equipment: Array.isArray(formData.equipment) ? formData.equipment : [],
|
equipment: Array.isArray(formData.equipment) ? formData.equipment : [],
|
||||||
focus_areas_multi: formData.focus_area_id
|
focus_areas_multi: mapFocus,
|
||||||
? [{ focus_area_id: formData.focus_area_id, is_primary: true }]
|
training_styles_multi: mapStyles,
|
||||||
: [],
|
training_types_multi: mapTTypes,
|
||||||
training_styles_multi: formData.training_style_id
|
target_groups_multi: mapTg,
|
||||||
? [{ training_style_id: formData.training_style_id, is_primary: true }]
|
|
||||||
: [],
|
|
||||||
target_groups_multi: [],
|
|
||||||
age_groups: formData.age_groups || [],
|
age_groups: formData.age_groups || [],
|
||||||
skills: (formData.skills || []).map((s) => ({
|
skills: (formData.skills || []).map((s) => ({
|
||||||
skill_id: s.skill_id,
|
skill_id: s.skill_id,
|
||||||
|
|
|
||||||
23
frontend/src/utils/htmlUtils.js
Normal file
23
frontend/src/utils/htmlUtils.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
/** Einfache HTML-Hilfen für Rich-Text (Trainer-Content, kein öffentliches CMS). */
|
||||||
|
|
||||||
|
export function stripHtmlToText(html) {
|
||||||
|
if (!html || typeof html !== 'string') return ''
|
||||||
|
const d = document.createElement('div')
|
||||||
|
d.innerHTML = html
|
||||||
|
return (d.textContent || '').replace(/\s+/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entfernt script/iframes und Event-Handler-Attribute grob. */
|
||||||
|
export function sanitizeTrainerHtml(html) {
|
||||||
|
if (!html || typeof html !== 'string') return ''
|
||||||
|
const d = document.createElement('div')
|
||||||
|
d.innerHTML = html
|
||||||
|
d.querySelectorAll('script, iframe, object, embed').forEach((n) => n.remove())
|
||||||
|
d.querySelectorAll('*').forEach((el) => {
|
||||||
|
for (const attr of [...el.attributes]) {
|
||||||
|
const n = attr.name.toLowerCase()
|
||||||
|
if (n.startsWith('on') || n === 'srcdoc') el.removeAttribute(attr.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return d.innerHTML
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user