feat: enhance UI and functionality for Skills and Exercises pages
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 29s

- Added new CSS styles for Skills and Exercises pages, improving layout and responsiveness.
- Refactored components to utilize new styles, enhancing visual consistency and user experience.
- Implemented horizontal scrollable navigation for exercises and skills tabs, improving usability on smaller screens.
- Updated button styles and introduced new class names for better maintainability and accessibility.
- Enhanced loading states and empty messages for improved user feedback during data fetching.
This commit is contained in:
Lars 2026-05-06 11:24:44 +02:00
parent 657f73d2c5
commit 68923b0364
5 changed files with 706 additions and 312 deletions

View File

@ -1425,6 +1425,29 @@ button.capture-shell__nav-item {
.admin-catalog-section {
padding: 14px;
}
.exercises-page-toolbar-tabs,
.skills-page__tabs-scroll {
margin-left: calc(-1 * max(12px, env(safe-area-inset-left, 0px)));
margin-right: calc(-1 * max(12px, env(safe-area-inset-right, 0px)));
padding-left: max(12px, env(safe-area-inset-left, 0px));
padding-right: max(12px, env(safe-area-inset-right, 0px));
padding-bottom: 4px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.exercises-page-toolbar-tabs::-webkit-scrollbar,
.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) */
@ -2302,6 +2325,427 @@ button.capture-shell__nav-item {
overscroll-behavior: contain;
}
.skills-page-modal.admin-modal-sheet {
max-width: min(600px, 100vw - 32px);
}
/* Admin Hierarchie: Detail-Panel */
.detail-panel__title {
margin-top: 0;
}
.detail-panel__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 20px;
}
.detail-panel__context {
padding: 12px;
margin-bottom: 20px;
border-radius: 8px;
background: var(--surface2);
color: var(--text2);
}
.detail-panel__unknown {
padding: 20px;
color: var(--text3);
}
/* Seite Fähigkeiten & Methoden */
.skills-page__loading {
padding: 2rem;
text-align: center;
}
.skills-page__tabs-scroll {
margin-bottom: 1.5rem;
}
.skills-page__intro-row {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 1rem;
}
.skills-page__intro-row p {
margin: 0;
flex: 1 1 12rem;
color: var(--text2);
}
.skills-page__empty {
margin: 0;
text-align: center;
color: var(--text2);
}
.skills-page__category {
margin-bottom: 2rem;
}
.skills-page__category-title {
margin: 0 0 1rem;
text-transform: capitalize;
}
.skills-page__card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.skills-page__card-grid--methods {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
.skills-page-card {
display: flex;
flex-direction: column;
height: 100%;
}
.skills-page-card__head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
margin-bottom: 0.5rem;
}
.skills-page-card__meta-block {
margin-bottom: 0.5rem;
}
.skills-page-card__title {
margin: 0;
font-size: 1rem;
}
.skills-page-card__title--method {
margin: 0 0 0.25rem;
}
.skills-page-card__abbr {
color: var(--text2);
font-size: 0.875rem;
margin-left: 0.5rem;
font-weight: 400;
}
.skills-page-card__badge {
flex-shrink: 0;
font-size: 0.875rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
background: var(--accent);
color: #fff;
}
.skills-page-card__meta-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.skills-page-card__chip {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
background: var(--surface2);
color: var(--text2);
}
.skills-page-card__desc {
margin: 0 0 1rem;
color: var(--text2);
font-size: 0.875rem;
}
.skills-page-card__actions {
display: flex;
gap: 0.5rem;
margin-top: auto;
}
.skills-page-card__grow {
flex: 1;
}
.skills-page-modal__footer {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1.5rem;
}
.skills-page-modal__submit {
flex: 1;
}
/* Übungsliste: Kopf, Modus-Segmente, Hinweise */
.exercises-page__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
gap: 8px;
}
.exercises-page__title {
margin: 0;
}
.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);
margin-top: 10px;
margin-bottom: 0;
line-height: 1.45;
}
.exercise-search-hint .btn {
margin-left: 6px;
vertical-align: middle;
}
.exercise-search-bar {
margin-bottom: 12px;
}
.exercise-search-bar__primary {
margin-bottom: 10px;
}
.exercise-bulk-toolbar {
margin-bottom: 12px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.exercise-bulk-toolbar__meta {
font-size: 12px;
color: var(--text3);
line-height: 1.4;
flex: 1 1 200px;
}
.exercises-list-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr));
gap: 12px;
}
.exercise-card-layout {
display: flex;
gap: 10px;
align-items: flex-start;
}
.exercise-card-layout__check {
margin-top: 4px;
flex-shrink: 0;
accent-color: var(--accent);
}
.exercise-card-body-flex {
flex: 1;
min-width: 0;
}
.exercise-card-title {
margin: 0 0 8px;
font-size: 1.05rem;
line-height: 1.3;
font-weight: 700;
}
.exercise-card-title a {
color: inherit;
text-decoration: none;
}
.exercise-card-title a:hover {
color: var(--accent-dark);
}
@media (prefers-color-scheme: dark) {
.exercise-card-title a:hover {
color: var(--accent);
}
}
.exercise-card-tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.exercise-card-summary {
color: var(--text2);
font-size: 13px;
line-height: 1.4;
margin: 0;
}
.exercises-meta-line {
font-size: 13px;
color: var(--text2);
margin: 0 0 10px;
}
.exercises-meta-line--muted {
color: var(--text3);
margin-bottom: 8px;
}
.exercises-load-more {
text-align: center;
margin-top: 16px;
}
.exercises-empty-text {
margin: 0;
color: var(--text2);
text-align: center;
}
/* Admin Hierarchie-Baum (Fokusbereich) */
.focus-tree-root {
margin-bottom: 12px;
}
.focus-tree-header {
display: flex;
align-items: stretch;
gap: 2px;
padding: 4px 4px 4px 6px;
border-radius: 10px;
border: 1px solid transparent;
background: transparent;
transition: background 0.12s, border-color 0.12s;
}
.focus-tree-header:hover {
background: var(--surface2);
border-color: var(--border);
}
.focus-tree-header--selected {
background: var(--accent);
border-color: var(--accent);
}
.focus-tree-header--selected:hover {
background: color-mix(in srgb, var(--accent) 94%, #000);
border-color: var(--accent);
}
.focus-tree-toggle {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
min-height: 36px;
margin: 0;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text2);
cursor: pointer;
align-self: center;
-webkit-tap-highlight-color: transparent;
}
.focus-tree-toggle:hover {
background: rgba(0, 0, 0, 0.05);
}
.focus-tree-header--selected .focus-tree-toggle {
color: #fff;
}
.focus-tree-header--selected .focus-tree-toggle:hover {
background: rgba(255, 255, 255, 0.12);
}
.focus-tree-header__label {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px 6px 2px;
margin: 0;
border: none;
background: transparent;
color: inherit;
font: inherit;
font-weight: 600;
text-align: left;
cursor: pointer;
border-radius: 8px;
-webkit-tap-highlight-color: transparent;
}
.focus-tree-emoji {
flex-shrink: 0;
line-height: 1;
}
.focus-tree-children {
margin-top: 8px;
margin-left: 8px;
padding-left: 12px;
border-left: 2px solid var(--border);
}
.focus-tree-group {
margin-bottom: 12px;
}
.focus-tree-group:last-child {
margin-bottom: 0;
}
.focus-tree-group__head {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text3);
margin-bottom: 6px;
text-transform: uppercase;
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.focus-tree-add-btn {
flex-shrink: 0;
}
.focus-tree-item {
padding: 8px 12px;
margin-bottom: 4px;
border-radius: 8px;
cursor: pointer;
background: var(--surface2);
color: var(--text1);
font-size: 14px;
border: 1px solid var(--border);
line-height: 1.35;
transition: background 0.12s, border-color 0.12s, color 0.12s;
}
.focus-tree-item:last-child {
margin-bottom: 0;
}
.focus-tree-item:hover {
border-color: var(--accent);
}
.focus-tree-item--selected {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.focus-tree-item--selected:hover {
border-color: var(--accent);
}
.focus-tree-item__abbr {
margin-left: 8px;
font-size: 12px;
opacity: 0.85;
}
.focus-tree-item__meta {
font-size: 11px;
margin-top: 4px;
line-height: 1.35;
opacity: 0.88;
}
.focus-tree-item--selected .focus-tree-item__abbr,
.focus-tree-item--selected .focus-tree-item__meta {
opacity: 0.95;
color: #fff;
}
@media (max-width: 1023px) {
.exercise-filter-chips-row {
flex-wrap: nowrap;
overflow-x: auto;
padding-bottom: 4px;
margin-left: -2px;
margin-right: -2px;
padding-left: 2px;
padding-right: 2px;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.exercise-filter-chips-row::-webkit-scrollbar {
display: none;
}
.focus-tree-children {
margin-left: 4px;
padding-left: 8px;
}
}
.exercise-filter-modal.admin-modal-sheet {
max-width: min(920px, calc(100dvw - 16px));
}

View File

@ -20,7 +20,7 @@ function DetailPanel({ item, onUpdate, focusAreas }) {
return <TrainingTypeDetail item={item} onUpdate={onUpdate} focusAreas={focusAreas} />
}
return <div style={{ padding: '20px', color: 'var(--text3)' }}>Unbekannter Typ: {type}</div>
return <div className="detail-panel__unknown">Unbekannter Typ: {type}</div>
}
function FocusAreaDetail({ item, onUpdate }) {
@ -57,7 +57,7 @@ function FocusAreaDetail({ item, onUpdate }) {
return (
<div>
<h2 style={{ marginTop: 0 }}>Fokusbereich bearbeiten</h2>
<h2 className="detail-panel__title">Fokusbereich bearbeiten</h2>
<div className="form-row">
<label className="form-label">Name *</label>
<input className="form-input" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
@ -81,11 +81,11 @@ function FocusAreaDetail({ item, onUpdate }) {
<option value="inactive">Inaktiv</option>
</select>
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '20px' }}>
<div className="detail-panel__actions">
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !form.name}>
{saving ? 'Speichert...' : 'Speichern'}
</button>
<button className="btn" onClick={handleDelete}>Löschen</button>
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
</div>
</div>
)
@ -130,7 +130,7 @@ function StyleDirectionDetail({ item, onUpdate, focusAreas }) {
return (
<div>
<h2 style={{ marginTop: 0 }}>Stilrichtung bearbeiten</h2>
<h2 className="detail-panel__title">Stilrichtung bearbeiten</h2>
<div className="form-row">
<label className="form-label">Name *</label>
<input className="form-input" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
@ -163,11 +163,11 @@ function StyleDirectionDetail({ item, onUpdate, focusAreas }) {
<option value="inactive">Inaktiv</option>
</select>
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '20px' }}>
<div className="detail-panel__actions">
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !form.name || !form.focus_area_id}>
{saving ? 'Speichert...' : 'Speichern'}
</button>
<button className="btn" onClick={handleDelete}>Löschen</button>
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
</div>
</div>
)
@ -212,7 +212,7 @@ function TrainingTypeDetail({ item, onUpdate, focusAreas }) {
return (
<div>
<h2 style={{ marginTop: 0 }}>Trainingstyp bearbeiten</h2>
<h2 className="detail-panel__title">Trainingstyp bearbeiten</h2>
<div className="form-row">
<label className="form-label">Name *</label>
<input className="form-input" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} />
@ -245,11 +245,11 @@ function TrainingTypeDetail({ item, onUpdate, focusAreas }) {
<option value="inactive">Inaktiv</option>
</select>
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '20px' }}>
<div className="detail-panel__actions">
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !form.name || !form.focus_area_id}>
{saving ? 'Speichert...' : 'Speichern'}
</button>
<button className="btn" onClick={handleDelete}>Löschen</button>
<button type="button" className="btn btn-danger" onClick={handleDelete}>Löschen</button>
</div>
</div>
)
@ -284,8 +284,8 @@ function CreateStyleDirectionForm({ item, onUpdate }) {
return (
<div>
<h2 style={{ marginTop: 0 }}>Neue Stilrichtung erstellen</h2>
<div style={{ padding: '12px', background: 'var(--surface2)', borderRadius: '8px', marginBottom: '20px', color: 'var(--text2)' }}>
<h2 className="detail-panel__title">Neue Stilrichtung erstellen</h2>
<div className="detail-panel__context">
Fokusbereich: <strong>{item.focus_area_name}</strong>
</div>
<div className="form-row">
@ -304,7 +304,7 @@ function CreateStyleDirectionForm({ item, onUpdate }) {
<label className="form-label">Sortierung</label>
<input className="form-input" type="number" value={form.sort_order} onChange={e => setForm({ ...form, sort_order: parseInt(e.target.value) || 0 })} />
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '20px' }}>
<div className="detail-panel__actions">
<button className="btn btn-primary" onClick={handleCreate} disabled={saving || !form.name}>
{saving ? 'Erstellt...' : 'Erstellen'}
</button>
@ -342,8 +342,8 @@ function CreateTrainingTypeForm({ item, onUpdate }) {
return (
<div>
<h2 style={{ marginTop: 0 }}>Neuen Trainingstyp erstellen</h2>
<div style={{ padding: '12px', background: 'var(--surface2)', borderRadius: '8px', marginBottom: '20px', color: 'var(--text2)' }}>
<h2 className="detail-panel__title">Neuen Trainingstyp erstellen</h2>
<div className="detail-panel__context">
Fokusbereich: <strong>{item.focus_area_name}</strong>
</div>
<div className="form-row">
@ -362,7 +362,7 @@ function CreateTrainingTypeForm({ item, onUpdate }) {
<label className="form-label">Sortierung</label>
<input className="form-input" type="number" value={form.sort_order} onChange={e => setForm({ ...form, sort_order: parseInt(e.target.value) || 0 })} />
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '20px' }}>
<div className="detail-panel__actions">
<button className="btn btn-primary" onClick={handleCreate} disabled={saving || !form.name}>
{saving ? 'Erstellt...' : 'Erstellen'}
</button>

View File

@ -1,4 +1,5 @@
import React from 'react'
import { ChevronDown, ChevronRight } from 'lucide-react'
function FocusAreaNode({ focusArea, expanded, onToggle, onSelect, selectedId, selectedType }) {
const nodeId = `fa-${focusArea.id}`
@ -6,82 +7,94 @@ function FocusAreaNode({ focusArea, expanded, onToggle, onSelect, selectedId, se
const isSelected = selectedType === 'focus_area' && selectedId === focusArea.id
return (
<div style={{ marginBottom: '12px' }}>
{/* Focus Area Header */}
<div
onClick={() => onSelect(focusArea, 'focus_area')}
style={{
display: 'flex',
alignItems: 'center',
padding: '8px 12px',
borderRadius: '8px',
cursor: 'pointer',
background: isSelected ? 'var(--accent)' : 'transparent',
color: isSelected ? 'white' : 'var(--text1)',
fontWeight: 600
}}
>
<span
onClick={(e) => { e.stopPropagation(); onToggle(nodeId) }}
style={{ marginRight: '8px', cursor: 'pointer', fontSize: '18px' }}
<div className="focus-tree-root">
<div className={'focus-tree-header' + (isSelected ? ' focus-tree-header--selected' : '')}>
<button
type="button"
className="focus-tree-toggle"
aria-expanded={isExpanded}
aria-label={isExpanded ? 'Bereich einklappen' : 'Bereich aufklappen'}
onClick={(e) => {
e.stopPropagation()
onToggle(nodeId)
}}
>
{isExpanded ? '▼' : '▶'}
</span>
<span style={{ marginRight: '8px' }}>{focusArea.icon}</span>
<span>{focusArea.name}</span>
{isExpanded ? (
<ChevronDown size={18} strokeWidth={2} aria-hidden />
) : (
<ChevronRight size={18} strokeWidth={2} aria-hidden />
)}
</button>
<button
type="button"
className="focus-tree-header__label"
onClick={() => onSelect(focusArea, 'focus_area')}
>
{focusArea.icon ? (
<span className="focus-tree-emoji" aria-hidden>
{focusArea.icon}
</span>
) : null}
<span>{focusArea.name}</span>
</button>
</div>
{/* Children: Style Directions + Training Types */}
{isExpanded && (
<div style={{ marginLeft: '28px', marginTop: '8px' }}>
{/* Style Directions Section */}
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '6px', textTransform: 'uppercase', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="focus-tree-children">
<div className="focus-tree-group">
<div className="focus-tree-group__head">
<span>Stilrichtungen</span>
<button
className="btn"
style={{ fontSize: '11px', padding: '4px 8px' }}
type="button"
className="btn btn-secondary btn-tiny focus-tree-add-btn"
onClick={(e) => {
e.stopPropagation()
onSelect({ _createType: 'style_direction', focus_area_id: focusArea.id, focus_area_name: focusArea.name }, 'create_style_direction')
onSelect(
{ _createType: 'style_direction', focus_area_id: focusArea.id, focus_area_name: focusArea.name },
'create_style_direction'
)
}}
>
+ Neu
</button>
</div>
{focusArea.style_directions && focusArea.style_directions.map(sd => (
<StyleDirectionNode
key={sd.id}
styleDirection={sd}
onSelect={onSelect}
isSelected={selectedType === 'style_direction' && selectedId === sd.id}
/>
))}
{focusArea.style_directions &&
focusArea.style_directions.map((sd) => (
<StyleDirectionNode
key={sd.id}
styleDirection={sd}
onSelect={onSelect}
isSelected={selectedType === 'style_direction' && selectedId === sd.id}
/>
))}
</div>
{/* Training Types Section */}
<div>
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '6px', textTransform: 'uppercase', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="focus-tree-group">
<div className="focus-tree-group__head">
<span>Trainingstypen</span>
<button
className="btn"
style={{ fontSize: '11px', padding: '4px 8px' }}
type="button"
className="btn btn-secondary btn-tiny focus-tree-add-btn"
onClick={(e) => {
e.stopPropagation()
onSelect({ _createType: 'training_type', focus_area_id: focusArea.id, focus_area_name: focusArea.name }, 'create_training_type')
onSelect(
{ _createType: 'training_type', focus_area_id: focusArea.id, focus_area_name: focusArea.name },
'create_training_type'
)
}}
>
+ Neu
</button>
</div>
{focusArea.training_types && focusArea.training_types.map(tt => (
<TrainingTypeNode
key={tt.id}
trainingType={tt}
onSelect={onSelect}
isSelected={selectedType === 'training_type' && selectedId === tt.id}
/>
))}
{focusArea.training_types &&
focusArea.training_types.map((tt) => (
<TrainingTypeNode
key={tt.id}
trainingType={tt}
onSelect={onSelect}
isSelected={selectedType === 'training_type' && selectedId === tt.id}
/>
))}
</div>
</div>
)}
@ -92,28 +105,26 @@ function FocusAreaNode({ focusArea, expanded, onToggle, onSelect, selectedId, se
function StyleDirectionNode({ styleDirection, onSelect, isSelected }) {
return (
<div
role="button"
tabIndex={0}
className={'focus-tree-item' + (isSelected ? ' focus-tree-item--selected' : '')}
onClick={() => onSelect(styleDirection, 'style_direction')}
style={{
padding: '6px 12px',
marginBottom: '4px',
borderRadius: '6px',
cursor: 'pointer',
background: isSelected ? 'var(--accent)' : 'var(--surface2)',
color: isSelected ? 'white' : 'var(--text1)',
fontSize: '14px'
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelect(styleDirection, 'style_direction')
}
}}
>
{styleDirection.name}
{styleDirection.abbreviation && (
<span style={{ marginLeft: '8px', opacity: 0.7, fontSize: '12px' }}>
({styleDirection.abbreviation})
</span>
)}
{styleDirection.target_groups && styleDirection.target_groups.length > 0 && (
<div style={{ fontSize: '11px', opacity: 0.8, marginTop: '4px' }}>
Zielgruppen: {styleDirection.target_groups.map(tg => tg.name).join(', ')}
{styleDirection.abbreviation ? (
<span className="focus-tree-item__abbr">({styleDirection.abbreviation})</span>
) : null}
{styleDirection.target_groups && styleDirection.target_groups.length > 0 ? (
<div className="focus-tree-item__meta">
Zielgruppen: {styleDirection.target_groups.map((tg) => tg.name).join(', ')}
</div>
)}
) : null}
</div>
)
}
@ -121,25 +132,23 @@ function StyleDirectionNode({ styleDirection, onSelect, isSelected }) {
function TrainingTypeNode({ trainingType, onSelect, isSelected }) {
return (
<div
role="button"
tabIndex={0}
className={'focus-tree-item' + (isSelected ? ' focus-tree-item--selected' : '')}
onClick={() => onSelect(trainingType, 'training_type')}
style={{
padding: '6px 12px',
marginBottom: '4px',
borderRadius: '6px',
cursor: 'pointer',
background: isSelected ? 'var(--accent)' : 'var(--surface2)',
color: isSelected ? 'white' : 'var(--text1)',
fontSize: '14px'
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelect(trainingType, 'training_type')
}
}}
>
{trainingType.name}
{trainingType.abbreviation && (
<span style={{ marginLeft: '8px', opacity: 0.7, fontSize: '12px' }}>
({trainingType.abbreviation})
</span>
)}
{trainingType.abbreviation ? (
<span className="focus-tree-item__abbr">({trainingType.abbreviation})</span>
) : null}
</div>
)
}
export default FocusAreaNode
export default FocusAreaNode

View File

@ -545,65 +545,64 @@ function ExercisesListPage() {
if (!catalogsReady && pageTab === 'list') {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="spinner"></div>
<p>Lade Kataloge</p>
<div className="app-page">
<div className="empty-state" style={{ padding: '2.5rem 1rem' }}>
<div className="spinner" />
<p className="muted" style={{ marginTop: '12px' }}>
Lade Kataloge
</p>
</div>
</div>
)
}
return (
<div className="app-page">
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '12px',
flexWrap: 'wrap',
gap: '8px',
}}
>
<h1 style={{ fontSize: '1.35rem' }}>Übungen</h1>
<div className="exercises-page__header">
<h1 className="page-title exercises-page__title">Übungen</h1>
{pageTab === 'list' ? (
<Link to="/exercises/new" className="btn btn-primary">
+ Neu
</Link>
) : (
<span />
<span aria-hidden="true" />
)}
</div>
<div
role="tablist"
aria-label="Übungen Bereiche"
style={{ display: 'flex', gap: '8px', marginBottom: '14px', flexWrap: 'wrap' }}
>
<button
type="button"
role="tab"
aria-selected={pageTab === 'list'}
className={pageTab === 'list' ? 'btn btn-primary' : 'btn btn-secondary'}
onClick={() => setPageTab('list')}
>
Liste
</button>
<button
type="button"
role="tab"
aria-selected={pageTab === 'progression'}
className={pageTab === 'progression' ? 'btn btn-primary' : 'btn btn-secondary'}
onClick={() => setPageTab('progression')}
>
Progressionsgraphen
</button>
<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>
{pageTab === 'progression' ? (
<ExerciseProgressionGraphPanel />
) : (
<>
<div className="card exercise-search-bar" style={{ marginBottom: '12px' }}>
<div className="card exercise-search-bar">
<label className="form-label">Volltextsuche (Titel, Ziel, )</label>
<datalist id="exercise-search-titles">
{searchTitleSuggestions.map((t) => (
@ -612,7 +611,7 @@ function ExercisesListPage() {
</datalist>
<input
type="search"
className="form-input"
className="form-input exercise-search-bar__primary"
placeholder="Suchbegriffe…"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
@ -620,7 +619,6 @@ function ExercisesListPage() {
name="exercise-fulltext-search"
list="exercise-search-titles"
enterKeyHint="search"
style={{ marginBottom: '10px' }}
/>
<label className="form-label">Ergänzende Suche / KI-Vorbereitung (Beta)</label>
<input
@ -668,13 +666,13 @@ function ExercisesListPage() {
))}
</div>
) : null}
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '10px', marginBottom: 0 }}>
<p className="exercise-search-hint">
Trefferliste aktualisiert sich kurz nach Eingabe. Titel der aktuellen Liste erscheinen als Vorschläge (Pfeil im
Feld). Fachliche Filter über Filter zwischen Feldern UND, Auswahl mehrerer Werte je Feld mit ODER.
{exercises.length > 0 ? (
<>
{' '}
<button type="button" className="btn btn-secondary" style={{ marginLeft: '6px' }} onClick={toggleSelectAllPage}>
<button type="button" className="btn btn-secondary btn-small" onClick={toggleSelectAllPage}>
{allOnPageSelected ? 'Auswahl auf dieser Seite aufheben' : 'Alle auf dieser Seite auswählen'}
</button>
</>
@ -683,24 +681,15 @@ function ExercisesListPage() {
</div>
{selectedIds.size > 0 ? (
<div
className="card"
style={{
marginBottom: '12px',
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '10px',
}}
>
<div className="card exercise-bulk-toolbar">
<strong>{selectedIds.size} ausgewählt</strong>
<button type="button" className="btn btn-secondary" onClick={clearSelection}>
<button type="button" className="btn btn-secondary btn-small" onClick={clearSelection}>
Auswahl aufheben
</button>
<button type="button" className="btn btn-primary" onClick={openBulkModal}>
<button type="button" className="btn btn-primary btn-small" onClick={openBulkModal}>
Massenänderung
</button>
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
<span className="exercise-bulk-toolbar__meta">
Bis zu {BULK_MAX_IDS} pro Anfrage. Für Verein ohne Auswahl: aktiver Vereinskontext (
<code>X-Active-Club-Id</code>
).
@ -736,7 +725,7 @@ function ExercisesListPage() {
</button>
</div>
<div className="admin-modal-sheet__body exercise-filter-modal__scroll">
<p style={{ fontSize: '13px', color: 'var(--text2)', marginTop: 0, marginBottom: '14px' }}>
<p className="muted" style={{ marginTop: 0, marginBottom: '14px' }}>
Zwischen den Bereichen gilt <strong>UND</strong>. Innerhalb eines Feldes werden mehrere Einträge mit{' '}
<strong>ODER</strong> verknüpft.
</p>
@ -900,12 +889,12 @@ function ExercisesListPage() {
</button>
</div>
<div className="admin-modal-sheet__body exercise-filter-modal__scroll">
<p style={{ fontSize: '13px', color: 'var(--text2)', marginTop: 0 }}>
<p className="muted" style={{ marginTop: 0 }}>
Es werden <strong>{selectedIds.size}</strong> Übung(en) aus der aktuellen Auswahl bearbeitet. Pro Durchlauf
höchstens {BULK_MAX_IDS}. Ohne Berechtigung bleiben Einzelübungen unverändert (siehe Hinweis nach dem
Speichern).
</p>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, marginBottom: '14px' }}>
<p className="form-sub" style={{ marginTop: 0, marginBottom: '14px' }}>
Unter Zuordnung ersetzen: die gewählte Liste ersetzt die bisherige Zuordnung bei allen betroffenen
Übungen vollständig (leere Auswahl = alle Zuordnungen dieser Kategorie entfernen). Die erste Auswahl gilt
als Primärzuordnung.
@ -1066,7 +1055,7 @@ function ExercisesListPage() {
</div>
</section>
</div>
<div className="exercise-filter-modal__footer" style={{ justifyContent: 'flex-end' }}>
<div className="exercise-filter-modal__footer">
<button
type="button"
className="btn"
@ -1084,52 +1073,43 @@ function ExercisesListPage() {
) : null}
{listFetching && exercises.length === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '2rem' }}>
<div className="spinner"></div>
<p style={{ color: 'var(--text2)', marginTop: '12px' }}>Lade Übungen</p>
<div className="card empty-state" style={{ padding: '2rem 1rem' }}>
<div className="spinner" />
<p className="muted" style={{ marginTop: '12px' }}>
Lade Übungen
</p>
</div>
) : exercises.length === 0 ? (
<div className="card">
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
Keine Übungen gefunden.
</p>
<p className="exercises-empty-text">Keine Übungen gefunden.</p>
</div>
) : (
<>
{listFetching ? (
<p style={{ fontSize: '13px', color: 'var(--text3)', marginBottom: '8px' }}>Aktualisiere Treffer</p>
<p className="exercises-meta-line exercises-meta-line--muted">Aktualisiere Treffer</p>
) : null}
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '10px' }}>
<p className="exercises-meta-line">
{exercises.length} angezeigt
{hasMore ? ' · es gibt weitere Einträge' : ''}
</p>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '12px',
}}
>
<div className="exercises-list-grid">
{exercises.map((exercise) => (
<div key={exercise.id} className="card exercise-card">
<div style={{ display: 'flex', gap: '10px', alignItems: 'flex-start' }}>
<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`}
style={{ marginTop: '4px', flexShrink: 0 }}
className="exercise-card-layout__check"
/>
<div className="exercise-card__body" style={{ flex: 1, minWidth: 0 }}>
<h3 style={{ marginBottom: '8px', fontSize: '1.05rem', lineHeight: 1.3 }}>
<Link
to={`/exercises/${exercise.id}`}
style={{ color: 'inherit', textDecoration: 'none' }}
>
<div className="exercise-card__body exercise-card-body-flex">
<h3 className="exercise-card-title">
<Link to={`/exercises/${exercise.id}`}>
{exercise.title}
</Link>
</h3>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', marginBottom: '8px' }}>
<div className="exercise-card-tags">
{exercise.focus_area && (
<span className="exercise-tag exercise-tag--accent">{exercise.focus_area}</span>
)}
@ -1137,7 +1117,7 @@ function ExercisesListPage() {
<span className="exercise-tag">{exercise.status}</span>
</div>
{exercise.summary && (
<p style={{ color: 'var(--text2)', fontSize: '13px', lineHeight: 1.4 }}>
<p className="exercise-card-summary">
{exercise.summary.length > 160
? `${exercise.summary.slice(0, 160)}`
: exercise.summary}
@ -1154,12 +1134,7 @@ function ExercisesListPage() {
</Link>
<button
type="button"
className="btn"
style={{
background: 'var(--danger)',
color: 'white',
border: 'none',
}}
className="btn btn-danger btn-small"
onClick={() => handleDelete(exercise)}
>
Löschen
@ -1169,7 +1144,7 @@ function ExercisesListPage() {
))}
</div>
{hasMore && (
<div style={{ textAlign: 'center', marginTop: '16px' }}>
<div className="exercises-load-more">
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
{loadingMore ? 'Laden…' : 'Mehr laden'}
</button>

View File

@ -132,7 +132,7 @@ function SkillsPage() {
if (loading) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="skills-page__loading">
<div className="spinner"></div>
<p>Laden...</p>
</div>
@ -143,40 +143,38 @@ function SkillsPage() {
const methodsByCategory = groupByCategory(methods)
return (
<div className="app-page">
<h1 style={{ marginBottom: '1.5rem' }}>Fähigkeiten & Methoden</h1>
<div className="app-page skills-page">
<h1 className="page-title">Fähigkeiten & Methoden</h1>
{/* Tabs */}
<div style={{
display: 'flex',
gap: '0.5rem',
marginBottom: '1.5rem',
borderBottom: '2px solid var(--border)'
}}>
<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"
>
{['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)}
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 === 'skills' ? 'Fähigkeiten' : 'Trainingsmethoden'}
</button>
))}
</div>
</div>
{/* Skills Tab */}
{activeTab === 'skills' && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
<p style={{ color: 'var(--text2)' }}>
<div className="skills-page__intro-row">
<p>
Fähigkeiten sind Kompetenzen, die in Übungen trainiert werden.
</p>
{isAdmin && (
@ -188,60 +186,46 @@ function SkillsPage() {
{Object.keys(skillsByCategory).length === 0 ? (
<div className="card">
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
<p className="skills-page__empty">
Keine Fähigkeiten gefunden
</p>
</div>
) : (
Object.keys(skillsByCategory).sort().map(category => (
<div key={category} style={{ marginBottom: '2rem' }}>
<h2 style={{ marginBottom: '1rem', textTransform: 'capitalize' }}>
<div key={category} className="skills-page__category">
<h2 className="skills-page__category-title">
{category}
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '1rem'
}}>
<div className="skills-page__card-grid">
{skillsByCategory[category].map(skill => (
<div key={skill.id} className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '0.5rem' }}>
<h3 style={{ fontSize: '1rem' }}>{skill.name}</h3>
<div key={skill.id} className="card skills-page-card">
<div className="skills-page-card__head">
<h3 className="skills-page-card__title">{skill.name}</h3>
{skill.importance && (
<span style={{
fontSize: '0.875rem',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
background: 'var(--accent)',
color: 'white'
}}>
<span className="skills-page-card__badge">
{skill.importance}/5
</span>
)}
</div>
{skill.description && (
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem' }}>
<p className="skills-page-card__desc">
{skill.description}
</p>
)}
{isAdmin && (
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
<div className="skills-page-card__actions">
<button
className="btn btn-secondary"
style={{ flex: 1 }}
type="button"
className="btn btn-secondary skills-page-card__grow"
onClick={() => handleEdit(skill, 'skill')}
>
Bearbeiten
</button>
<button
className="btn"
style={{
background: 'var(--danger)',
color: 'white',
border: 'none'
}}
type="button"
className="btn btn-danger"
onClick={() => handleDelete(skill, 'skill')}
>
Löschen
@ -260,8 +244,8 @@ function SkillsPage() {
{/* Methods Tab */}
{activeTab === 'methods' && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
<p style={{ color: 'var(--text2)' }}>
<div className="skills-page__intro-row">
<p>
Trainingsmethoden sind didaktische Ansätze für die Trainingsgestaltung.
</p>
{isAdmin && (
@ -273,52 +257,36 @@ function SkillsPage() {
{Object.keys(methodsByCategory).length === 0 ? (
<div className="card">
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
<p className="skills-page__empty">
Keine Trainingsmethoden gefunden
</p>
</div>
) : (
Object.keys(methodsByCategory).sort().map(category => (
<div key={category} style={{ marginBottom: '2rem' }}>
<h2 style={{ marginBottom: '1rem', textTransform: 'capitalize' }}>
<div key={category} className="skills-page__category">
<h2 className="skills-page__category-title">
{category}
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
gap: '1rem'
}}>
<div className="skills-page__card-grid skills-page__card-grid--methods">
{methodsByCategory[category].map(method => (
<div key={method.id} className="card">
<div style={{ marginBottom: '0.5rem' }}>
<h3 style={{ fontSize: '1rem', marginBottom: '0.25rem' }}>
<div key={method.id} className="card skills-page-card">
<div className="skills-page-card__meta-block">
<h3 className="skills-page-card__title skills-page-card__title--method">
{method.name}
{method.abbreviation && (
<span style={{ color: 'var(--text2)', fontSize: '0.875rem', marginLeft: '0.5rem' }}>
<span className="skills-page-card__abbr">
({method.abbreviation})
</span>
)}
</h3>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<div className="skills-page-card__meta-row">
{method.typical_duration && (
<span style={{
fontSize: '0.75rem',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
background: 'var(--surface2)',
color: 'var(--text2)'
}}>
<span className="skills-page-card__chip">
{method.typical_duration} min
</span>
)}
{method.typical_group_size && (
<span style={{
fontSize: '0.75rem',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
background: 'var(--surface2)',
color: 'var(--text2)'
}}>
<span className="skills-page-card__chip">
👥 {method.typical_group_size}
</span>
)}
@ -326,27 +294,23 @@ function SkillsPage() {
</div>
{method.description && (
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem' }}>
<p className="skills-page-card__desc">
{method.description}
</p>
)}
{isAdmin && (
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
<div className="skills-page-card__actions">
<button
className="btn btn-secondary"
style={{ flex: 1 }}
type="button"
className="btn btn-secondary skills-page-card__grow"
onClick={() => handleEdit(method, 'method')}
>
Bearbeiten
</button>
<button
className="btn"
style={{
background: 'var(--danger)',
color: 'white',
border: 'none'
}}
type="button"
className="btn btn-danger"
onClick={() => handleDelete(method, 'method')}
>
Löschen
@ -364,36 +328,37 @@ function SkillsPage() {
{/* Modal */}
{showModal && isAdmin && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '1rem'
}}>
<div style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '2rem',
maxWidth: '600px',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto'
}}>
<h2 style={{ marginBottom: '1.5rem' }}>
{editing
? (modalType === 'skill' ? 'Fähigkeit bearbeiten' : 'Methode bearbeiten')
: (modalType === 'skill' ? 'Neue Fähigkeit' : 'Neue Methode')
}
</h2>
<form onSubmit={handleSubmit}>
<div
className="admin-modal-backdrop"
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget) setShowModal(false)
}}
>
<div
className="admin-modal-sheet skills-page-modal"
role="dialog"
aria-modal="true"
aria-labelledby="skills-page-modal-title"
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<h2 id="skills-page-modal-title" className="admin-modal-sheet__title">
{editing
? (modalType === 'skill' ? 'Fähigkeit bearbeiten' : 'Methode bearbeiten')
: (modalType === 'skill' ? 'Neue Fähigkeit' : 'Neue Methode')
}
</h2>
<button
type="button"
className="btn btn-secondary admin-modal-sheet__close"
onClick={() => setShowModal(false)}
>
Schließen
</button>
</div>
<div className="admin-modal-sheet__body">
<form onSubmit={handleSubmit}>
<div className="form-row">
<label className="form-label">Name *</label>
<input
@ -455,7 +420,7 @@ function SkillsPage() {
{modalType === 'method' && (
<>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two">
<div className="form-row">
<label className="form-label">Typische Dauer (min)</label>
<input
@ -492,8 +457,8 @@ function SkillsPage() {
</select>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.5rem' }}>
<button type="submit" className="btn btn-primary" style={{ flex: 1 }}>
<div className="skills-page-modal__footer">
<button type="submit" className="btn btn-primary skills-page-modal__submit">
{editing ? 'Speichern' : 'Erstellen'}
</button>
<button
@ -505,6 +470,7 @@ function SkillsPage() {
</button>
</div>
</form>
</div>
</div>
</div>
)}