- 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.
229 lines
7.5 KiB
JavaScript
229 lines
7.5 KiB
JavaScript
import React, { useState, useEffect } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import api from '../utils/api'
|
|
|
|
const PAGE_SIZE = 100
|
|
|
|
function ExercisesListPage() {
|
|
const [exercises, setExercises] = useState([])
|
|
const [focusAreas, setFocusAreas] = useState([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [loadingMore, setLoadingMore] = useState(false)
|
|
const [offset, setOffset] = useState(0)
|
|
const [hasMore, setHasMore] = useState(false)
|
|
const [filters, setFilters] = useState({
|
|
focus_area: '',
|
|
visibility: '',
|
|
status: '',
|
|
})
|
|
|
|
useEffect(() => {
|
|
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])
|
|
|
|
const loadMore = async () => {
|
|
if (loadingMore || !hasMore) return
|
|
setLoadingMore(true)
|
|
try {
|
|
const batch = await api.listExercises({ ...filters, limit: PAGE_SIZE, offset })
|
|
setExercises((prev) => [...prev, ...batch])
|
|
setHasMore(batch.length === PAGE_SIZE)
|
|
setOffset((o) => o + batch.length)
|
|
} catch (err) {
|
|
alert('Fehler: ' + err.message)
|
|
} finally {
|
|
setLoadingMore(false)
|
|
}
|
|
}
|
|
|
|
const handleDelete = async (exercise) => {
|
|
if (!confirm(`Übung "${exercise.title}" wirklich löschen?`)) return
|
|
try {
|
|
await api.deleteExercise(exercise.id)
|
|
setExercises((prev) => prev.filter((e) => e.id !== exercise.id))
|
|
} catch (err) {
|
|
alert('Fehler beim Löschen: ' + err.message)
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
|
<div className="spinner"></div>
|
|
<p>Laden...</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div style={{ padding: '12px', maxWidth: '1200px', margin: '0 auto' }}>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: '12px',
|
|
flexWrap: 'wrap',
|
|
gap: '8px',
|
|
}}
|
|
>
|
|
<h1 style={{ fontSize: '1.35rem' }}>Übungen</h1>
|
|
<Link to="/exercises/new" className="btn btn-primary">
|
|
+ Neu
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="card exercise-filters-compact" style={{ marginBottom: '12px' }}>
|
|
<div>
|
|
<label className="form-label">Fokus</label>
|
|
<select
|
|
className="form-input"
|
|
value={filters.focus_area}
|
|
onChange={(e) => setFilters({ ...filters, focus_area: e.target.value })}
|
|
>
|
|
<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>
|
|
|
|
{exercises.length === 0 ? (
|
|
<div className="card">
|
|
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
|
Keine Übungen gefunden.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '10px' }}>
|
|
{exercises.length} angezeigt
|
|
{hasMore ? ' · es gibt weitere Einträge' : ''}
|
|
</p>
|
|
<div
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
|
gap: '12px',
|
|
}}
|
|
>
|
|
{exercises.map((exercise) => (
|
|
<div key={exercise.id} className="card exercise-card">
|
|
<div className="exercise-card__body">
|
|
<h3 style={{ marginBottom: '8px', fontSize: '1.05rem', lineHeight: 1.3 }}>
|
|
<Link
|
|
to={`/exercises/${exercise.id}`}
|
|
style={{ color: 'inherit', textDecoration: 'none' }}
|
|
>
|
|
{exercise.title}
|
|
</Link>
|
|
</h3>
|
|
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', marginBottom: '8px' }}>
|
|
{exercise.focus_area && (
|
|
<span className="exercise-tag exercise-tag--accent">{exercise.focus_area}</span>
|
|
)}
|
|
<span className="exercise-tag">{exercise.visibility}</span>
|
|
<span className="exercise-tag">{exercise.status}</span>
|
|
</div>
|
|
{exercise.summary && (
|
|
<p style={{ color: 'var(--text2)', fontSize: '13px', lineHeight: 1.4 }}>
|
|
{exercise.summary.length > 160
|
|
? `${exercise.summary.slice(0, 160)}…`
|
|
: exercise.summary}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="exercise-card__actions">
|
|
<Link to={`/exercises/${exercise.id}`} className="btn btn-secondary">
|
|
Ansehen
|
|
</Link>
|
|
<Link to={`/exercises/${exercise.id}/edit`} className="btn btn-secondary">
|
|
Bearbeiten
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
style={{
|
|
background: 'var(--danger)',
|
|
color: 'white',
|
|
border: 'none',
|
|
}}
|
|
onClick={() => handleDelete(exercise)}
|
|
>
|
|
Löschen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{hasMore && (
|
|
<div style={{ textAlign: 'center', marginTop: '16px' }}>
|
|
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
|
|
{loadingMore ? 'Laden…' : 'Mehr laden'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default ExercisesListPage
|