All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m2s
- Bumped APP_VERSION to 0.8.119 and updated the changelog to reflect new features. - Introduced the ExerciseListCard component and implemented lazy loading for the Progression Tab using React's Suspense. - Enhanced the ExercisePickerModal with virtualization for improved performance using @tanstack/react-virtual. - Updated documentation to reflect the new app version and its corresponding changes.
175 lines
6.0 KiB
JavaScript
175 lines
6.0 KiB
JavaScript
import React from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import {
|
|
Eye,
|
|
Pencil,
|
|
Trash2,
|
|
Globe,
|
|
Users,
|
|
Lock,
|
|
CheckCircle2,
|
|
Archive,
|
|
CircleDot,
|
|
FilePenLine,
|
|
} from 'lucide-react'
|
|
import ExerciseRichTextBlock from '../ExerciseRichTextBlock'
|
|
import { coerceApiNameList } from '../../utils/sanitizeHtml'
|
|
import { canUserRequestExerciseDelete } from '../../utils/exercisePermissions'
|
|
|
|
const VIS_LABELS = { official: 'Global', club: 'Verein', private: 'Privat' }
|
|
const STATUS_LABELS = {
|
|
draft: 'Entwurf',
|
|
in_review: 'In Prüfung',
|
|
approved: 'Freigegeben',
|
|
archived: 'Archiv',
|
|
}
|
|
|
|
function visibilityLabel(v) {
|
|
return VIS_LABELS[v] || v || '—'
|
|
}
|
|
|
|
function statusLabel(s) {
|
|
return STATUS_LABELS[s] || s || '—'
|
|
}
|
|
|
|
function exerciseFocusNames(ex) {
|
|
const fromApi = coerceApiNameList(ex.focus_area_names)
|
|
if (fromApi.length) return fromApi
|
|
if (ex.focus_area) return [ex.focus_area]
|
|
return []
|
|
}
|
|
|
|
function exerciseCardClassName(exercise, userId) {
|
|
const vis = exercise.visibility || 'private'
|
|
const visKey = vis === 'official' || vis === 'club' || vis === 'private' ? vis : 'private'
|
|
const mine = userId != null && Number(exercise.created_by) === Number(userId)
|
|
return ['card', 'exercise-card', `exercise-card--scope-${visKey}`, mine ? 'exercise-card--mine' : '']
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
}
|
|
|
|
function ExerciseCardScopeStatus({ exercise }) {
|
|
const v = exercise.visibility || 'private'
|
|
const s = exercise.status || 'draft'
|
|
const visLabel = visibilityLabel(v)
|
|
const stLabel = statusLabel(s)
|
|
const tip = `${visLabel} · ${stLabel}`
|
|
let VisIcon = Lock
|
|
if (v === 'official') VisIcon = Globe
|
|
else if (v === 'club') VisIcon = Users
|
|
let StatIcon = FilePenLine
|
|
if (s === 'approved') StatIcon = CheckCircle2
|
|
else if (s === 'archived') StatIcon = Archive
|
|
else if (s === 'in_review') StatIcon = CircleDot
|
|
return (
|
|
<div
|
|
className="exercise-card__meta-compact"
|
|
title={tip}
|
|
aria-label={`Sichtbarkeit: ${visLabel}. Status: ${stLabel}.`}
|
|
>
|
|
<span className="exercise-card__meta-glyph">
|
|
<VisIcon size={15} strokeWidth={2} aria-hidden />
|
|
</span>
|
|
<span className="exercise-card__meta-sep" aria-hidden>
|
|
·
|
|
</span>
|
|
<span className="exercise-card__meta-glyph">
|
|
<StatIcon size={15} strokeWidth={2} aria-hidden />
|
|
</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Kartenzeile in der Übungsliste (Fokus/Planung — keine Virtualisierung im Grid, dafür content-visibility in app.css).
|
|
*/
|
|
export default function ExerciseListCard({ exercise, user, selectedIds, toggleSelect, onDelete }) {
|
|
const focusNames = exerciseFocusNames(exercise)
|
|
const styleNames = coerceApiNameList(exercise.style_direction_names)
|
|
const typeNames = coerceApiNameList(exercise.training_type_names)
|
|
return (
|
|
<div className={exerciseCardClassName(exercise, user?.id)}>
|
|
<div className="exercise-card-layout exercise-card-layout--grow">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedIds.has(Number(exercise.id))}
|
|
onChange={() => toggleSelect(exercise.id)}
|
|
aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`}
|
|
className="exercise-card-layout__check"
|
|
/>
|
|
<div className="exercise-card__body exercise-card-body-flex">
|
|
<h3 className="exercise-card-title">
|
|
<Link to={`/exercises/${exercise.id}`}>{exercise.title}</Link>
|
|
</h3>
|
|
<div className="exercise-card-tags">
|
|
{focusNames.map((name) => (
|
|
<span key={`fa:${name}`} className="exercise-tag exercise-tag--accent">
|
|
{name}
|
|
</span>
|
|
))}
|
|
{styleNames.map((name) => (
|
|
<span key={`sd:${name}`} className="exercise-tag exercise-tag--style">
|
|
{name}
|
|
</span>
|
|
))}
|
|
{typeNames.map((name) => (
|
|
<span key={`tt:${name}`} className="exercise-tag exercise-tag--training">
|
|
{name}
|
|
</span>
|
|
))}
|
|
{(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' ? (
|
|
<span
|
|
className="exercise-tag"
|
|
style={{ background: 'var(--accent-soft)', color: 'var(--accent-dark)' }}
|
|
>
|
|
Kombination
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
{exercise.summary && String(exercise.summary).trim() ? (
|
|
<div className="exercise-card-summary exercise-card-summary--rich">
|
|
<ExerciseRichTextBlock
|
|
html={exercise.summary}
|
|
exerciseId={exercise.id}
|
|
media={exercise.media || []}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className="exercise-card__footer">
|
|
<ExerciseCardScopeStatus exercise={exercise} />
|
|
<div className="exercise-card__actions exercise-card__actions--icons">
|
|
<Link
|
|
to={`/exercises/${exercise.id}`}
|
|
className="exercise-card__icon-btn"
|
|
title="Ansehen"
|
|
aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ ansehen`}
|
|
>
|
|
<Eye size={18} strokeWidth={2} aria-hidden />
|
|
</Link>
|
|
<Link
|
|
to={`/exercises/${exercise.id}/edit`}
|
|
className="exercise-card__icon-btn"
|
|
title="Bearbeiten"
|
|
aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ bearbeiten`}
|
|
>
|
|
<Pencil size={18} strokeWidth={2} aria-hidden />
|
|
</Link>
|
|
{canUserRequestExerciseDelete(user, exercise) ? (
|
|
<button
|
|
type="button"
|
|
className="exercise-card__icon-btn exercise-card__icon-btn--danger"
|
|
title="Löschen"
|
|
aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ löschen`}
|
|
onClick={() => onDelete(exercise)}
|
|
>
|
|
<Trash2 size={18} strokeWidth={2} aria-hidden />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|