shinkan-jinkendo/frontend/src/components/exercises/ExerciseListCard.jsx
Lars 9da29a2231
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
chore(version): update version and changelog for release 0.8.119
- 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.
2026-05-14 08:59:06 +02:00

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>
)
}