shinkan-jinkendo/frontend/src/components/exercises/ExercisesListPageRoot.jsx
Lars 4588ef4c7e
Some checks failed
Deploy Development / deploy (push) Failing after 24s
Test Suite / pytest-backend (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Failing after 7s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m22s
Refactor navigation components and enhance return context handling
- Replaced `PageReturnLink` with `PageReturnButton` for consistent back navigation across various pages.
- Updated multiple components, including `ExercisePeekModal`, `PageFormEditorChrome`, and `ExerciseDetailPage`, to utilize the new return context features.
- Enhanced CSS styles for the new return button to improve visual consistency.
- Improved navigation logic in `TrainingFrameworkProgramEditPage` and `TrainingModuleEditPage` to ensure seamless user experience when navigating back to previous locations.
2026-05-20 07:42:46 +02:00

701 lines
25 KiB
JavaScript

import React, { useState, useEffect, useMemo, useCallback, useRef, lazy, Suspense } from 'react'
import api from '../../utils/api'
import { useAuth } from '../../context/AuthContext'
import { activeClubMemberships, getTenantClubDependencyKey } from '../../utils/activeClub'
import PageSectionNav from '../PageSectionNav'
import ExerciseListCard from './ExerciseListCard'
import ExerciseListFilterModal from './ExerciseListFilterModal'
import ExerciseListBulkModal from './ExerciseListBulkModal'
import ExerciseListSearchBar from './ExerciseListSearchBar'
import ExerciseListBulkToolbar from './ExerciseListBulkToolbar'
import SaveSelectedExercisesAsModuleModal from './SaveSelectedExercisesAsModuleModal'
import ExercisePeekModal from '../ExercisePeekModal'
import NavStateLink from '../NavStateLink'
import { buildExercisesListReturnContext } from '../../utils/navReturnContext'
import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips'
import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../../utils/exerciseListQuery'
import { readExerciseListSessionState, writeExerciseListSessionState } from '../../utils/exerciseListSessionState'
import {
mergeSelectedWithListEntries,
normalizeSelectedEntries,
snapshotExerciseForSelection,
} from '../../utils/exerciseListSelection'
import { useExerciseListCatalogsAndQuery } from '../../hooks/useExerciseListCatalogsAndQuery'
import {
INITIAL_EXERCISE_LIST_FILTERS,
mergeExerciseListPrefsFromApi,
compactExerciseListPrefsPayload,
} from '../../constants/exerciseListFilters'
const ExerciseProgressionGraphPanel = lazy(() => import('../ExerciseProgressionGraphPanel'))
const BULK_MAX_IDS = 500
const EXERCISES_PAGE_TABS = [
{ id: 'list', label: 'Liste' },
{ id: 'progression', label: 'Progressionsgraphen' },
]
function ExercisesListPageRoot() {
const { user, checkAuth } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const isSuperadmin = user?.role === 'superadmin'
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const [mineOnly, setMineOnly] = useState(() => {
try {
const sp = new URLSearchParams(window.location.search)
return sp.get('mine') === '1' || sp.get('created_by_me') === '1'
} catch {
return false
}
})
const [searchInput, setSearchInput] = useState('')
const [aiSearchInput, setAiSearchInput] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [debouncedAiSearch, setDebouncedAiSearch] = useState('')
const [filters, setFilters] = useState(() => ({ ...INITIAL_EXERCISE_LIST_FILTERS }))
const [filterModalOpen, setFilterModalOpen] = useState(false)
const [savingExercisePrefs, setSavingExercisePrefs] = useState(false)
const [pageTab, setPageTab] = useState('list')
const prefsAppliedRef = useRef(false)
const [selectedEntries, setSelectedEntries] = useState(() => [])
const [bulkModalOpen, setBulkModalOpen] = useState(false)
const [bulkVisibility, setBulkVisibility] = useState('')
const [bulkStatus, setBulkStatus] = useState('')
const [bulkClubSelect, setBulkClubSelect] = useState('')
const [bulkClubManual, setBulkClubManual] = useState('')
const [bulkSubmitting, setBulkSubmitting] = useState(false)
const [bulkPatchFocusAreas, setBulkPatchFocusAreas] = useState(false)
const [bulkFocusAreaIds, setBulkFocusAreaIds] = useState([])
const [bulkPatchStyleDirections, setBulkPatchStyleDirections] = useState(false)
const [bulkStyleDirectionIds, setBulkStyleDirectionIds] = useState([])
const [bulkPatchTrainingTypes, setBulkPatchTrainingTypes] = useState(false)
const [bulkTrainingTypeIds, setBulkTrainingTypeIds] = useState([])
const [bulkPatchTargetGroups, setBulkPatchTargetGroups] = useState(false)
const [bulkTargetGroupIds, setBulkTargetGroupIds] = useState([])
const [peekExercise, setPeekExercise] = useState(null)
const [saveModuleModalOpen, setSaveModuleModalOpen] = useState(false)
useEffect(() => {
if (!user?.id) return
if (prefsAppliedRef.current) return
const session = readExerciseListSessionState()
const merged = mergeExerciseListPrefsFromApi(user.exercise_list_prefs)
const filtersFromSession = session?.filters
setFilters(applyDashboardExerciseListUrl(filtersFromSession ?? merged))
if (session) {
setSearchInput(session.searchInput || '')
setAiSearchInput(session.aiSearchInput || '')
setMineOnly(session.mineOnly)
setSelectedEntries(normalizeSelectedEntries(session.selectedEntries))
}
try {
const sp = new URLSearchParams(window.location.search)
if (sp.get('mine') === '1' || sp.get('created_by_me') === '1') setMineOnly(true)
} catch {
/* ignore */
}
prefsAppliedRef.current = true
}, [user?.id, user?.exercise_list_prefs])
useEffect(() => {
if (!prefsAppliedRef.current) return
writeExerciseListSessionState({
filters,
searchInput,
aiSearchInput,
mineOnly,
selectedEntries,
})
}, [filters, searchInput, aiSearchInput, mineOnly, selectedEntries])
useEffect(() => {
if (!user?.id) prefsAppliedRef.current = false
}, [user?.id])
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
return () => clearTimeout(t)
}, [searchInput])
useEffect(() => {
const t = setTimeout(() => setDebouncedAiSearch(aiSearchInput.trim()), 400)
return () => clearTimeout(t)
}, [aiSearchInput])
useEffect(() => {
if (!filterModalOpen) return
const onKey = (e) => {
if (e.key === 'Escape') setFilterModalOpen(false)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [filterModalOpen])
const queryBase = useMemo(
() => buildExerciseListQueryBase(filters, debouncedSearch, debouncedAiSearch, mineOnly),
[filters, debouncedSearch, debouncedAiSearch, mineOnly]
)
const {
catalogs,
catalogsReady,
exercises,
setExercises,
listFetching,
loadingMore,
hasMore,
loadMore,
} = useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey })
const selectedIds = useMemo(
() => new Set(selectedEntries.map((e) => Number(e.id)).filter((id) => Number.isFinite(id) && id > 0)),
[selectedEntries]
)
const selectedExercisesDisplay = useMemo(
() => mergeSelectedWithListEntries(selectedEntries, exercises),
[selectedEntries, exercises]
)
const filterResultExercises = useMemo(
() => exercises.filter((e) => !selectedIds.has(Number(e.id))),
[exercises, selectedIds]
)
const focusOptions = useMemo(
() =>
catalogs.focusAreas.map((fa) => ({
id: fa.id,
label: `${fa.icon || ''} ${fa.name || ''}`.trim(),
})),
[catalogs.focusAreas]
)
const styleOptions = useMemo(
() => catalogs.styleDirections.map((s) => ({ id: s.id, label: s.name || String(s.id) })),
[catalogs.styleDirections]
)
const trainingTypeOptions = useMemo(
() => catalogs.trainingTypes.map((t) => ({ id: t.id, label: t.name || String(t.id) })),
[catalogs.trainingTypes]
)
const targetGroupOptions = useMemo(
() => catalogs.targetGroups.map((g) => ({ id: g.id, label: g.name || String(g.id) })),
[catalogs.targetGroups]
)
const skillOptions = useMemo(
() => catalogs.skills.map((s) => ({ id: s.id, label: s.name || String(s.id) })),
[catalogs.skills]
)
const visibilityOptions = useMemo(
() => [
{ id: 'private', label: 'Privat' },
{ id: 'club', label: 'Verein' },
{ id: 'official', label: 'Offiziell' },
],
[]
)
const statusOptions = useMemo(
() => [
{ id: 'draft', label: 'Entwurf' },
{ id: 'in_review', label: 'In Prüfung' },
{ id: 'approved', label: 'Freigegeben' },
{ id: 'archived', label: 'Archiviert' },
],
[]
)
const filterChips = useMemo(
() =>
buildExerciseListFilterChips({
mineOnly,
setMineOnly,
filters,
setFilters,
focusOptions,
styleOptions,
trainingTypeOptions,
targetGroupOptions,
skillOptions,
visibilityOptions,
statusOptions,
}),
[
mineOnly,
filters,
focusOptions,
styleOptions,
trainingTypeOptions,
targetGroupOptions,
skillOptions,
visibilityOptions,
statusOptions,
]
)
/** Für Browser-/datalist-Vorschläge (aktuelle Treffer-Titel, begrenzt) */
const searchTitleSuggestions = useMemo(() => {
const titles = exercises.map((e) => (e.title || '').trim()).filter(Boolean)
return [...new Set(titles)].slice(0, 80)
}, [exercises])
const clubNameById = useMemo(() => {
const m = {}
for (const c of activeClubMemberships(user?.clubs)) {
if (c?.id != null) m[Number(c.id)] = c.name || `#${c.id}`
}
return m
}, [user?.clubs])
const effectiveClubId =
user?.effective_club_id != null && user.effective_club_id !== ''
? Number(user.effective_club_id)
: user?.active_club_id != null && user.active_club_id !== ''
? Number(user.active_club_id)
: null
const toggleSelect = useCallback((exercise) => {
const snap = snapshotExerciseForSelection(exercise)
if (!snap) return
setSelectedEntries((prev) => {
const idx = prev.findIndex((e) => Number(e.id) === snap.id)
if (idx >= 0) return prev.filter((_, i) => i !== idx)
return [...prev, snap]
})
}, [])
const clearSelection = useCallback(() => setSelectedEntries([]), [])
const toggleSelectAllPage = useCallback(() => {
setSelectedEntries((prev) => {
const ids = new Set(prev.map((e) => Number(e.id)))
const pageIds = filterResultExercises.map((e) => Number(e.id))
const allSel = pageIds.length > 0 && pageIds.every((id) => ids.has(id))
if (allSel) {
const remove = new Set(pageIds)
return prev.filter((e) => !remove.has(Number(e.id)))
}
const next = [...prev]
for (const ex of filterResultExercises) {
const snap = snapshotExerciseForSelection(ex)
if (!snap || ids.has(snap.id)) continue
ids.add(snap.id)
next.push(snap)
}
return next
})
}, [filterResultExercises])
const allOnPageSelected = useMemo(
() =>
filterResultExercises.length > 0 &&
filterResultExercises.every((e) => selectedIds.has(Number(e.id))),
[filterResultExercises, selectedIds]
)
const selectedExercisesInListOrder = selectedExercisesDisplay
const exercisesModuleReturnContext = useMemo(() => buildExercisesListReturnContext(), [])
const bulkVisibilityOptions = useMemo(() => {
const base = [
{ id: '', label: '— nicht ändern —' },
{ id: 'private', label: 'Privat' },
{ id: 'club', label: 'Verein' },
]
if (isSuperadmin) base.push({ id: 'official', label: 'Offiziell (global)' })
return base
}, [isSuperadmin])
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))
setSelectedEntries((prev) => prev.filter((e) => Number(e.id) !== Number(exercise.id)))
} catch (err) {
alert('Fehler beim Löschen: ' + err.message)
}
}
const resetAllFilters = useCallback(() => {
setMineOnly(false)
setFilters({ ...INITIAL_EXERCISE_LIST_FILTERS })
}, [])
const handleSaveExerciseFilterPrefs = useCallback(async () => {
const uid = user?.id
if (!uid) {
alert('Nicht angemeldet.')
return
}
setSavingExercisePrefs(true)
try {
const payload = compactExerciseListPrefsPayload(filters)
await api.updateProfile(uid, { exercise_list_prefs: payload })
await checkAuth()
alert('Standardfilter für die Übungsliste gespeichert.')
} catch (e) {
alert('Speichern fehlgeschlagen: ' + (e.message || String(e)))
} finally {
setSavingExercisePrefs(false)
}
}, [user?.id, filters, checkAuth])
const openBulkModal = () => {
setBulkVisibility('')
setBulkStatus('')
setBulkClubSelect('')
setBulkClubManual('')
setBulkPatchFocusAreas(false)
setBulkFocusAreaIds([])
setBulkPatchStyleDirections(false)
setBulkStyleDirectionIds([])
setBulkPatchTrainingTypes(false)
setBulkTrainingTypeIds([])
setBulkPatchTargetGroups(false)
setBulkTargetGroupIds([])
setBulkModalOpen(true)
}
const handleBulkSubmit = async () => {
const anyRelationPatch =
bulkPatchFocusAreas ||
bulkPatchStyleDirections ||
bulkPatchTrainingTypes ||
bulkPatchTargetGroups
if (!bulkVisibility && !bulkStatus && !anyRelationPatch) {
alert(
'Bitte mindestens eine Änderung wählen (Sichtbarkeit, Status oder eine der Zuordnungen mit gesetztem Häkchen „ersetzen“).'
)
return
}
const ids = Array.from(selectedIds).filter((x) => Number.isFinite(x) && x > 0)
if (ids.length === 0) {
alert('Keine Übungen ausgewählt.')
return
}
if (ids.length > BULK_MAX_IDS) {
alert(`Maximal ${BULK_MAX_IDS} Übungen pro Vorgang. Bitte Auswahl oder mehrere Durchläufe verwenden.`)
return
}
const payload = { exercise_ids: ids }
if (bulkVisibility) payload.visibility = bulkVisibility
if (bulkStatus) payload.status = bulkStatus
if (bulkPatchFocusAreas) {
payload.focus_area_ids = bulkFocusAreaIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
}
if (bulkPatchStyleDirections) {
payload.style_direction_ids = bulkStyleDirectionIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
}
if (bulkPatchTrainingTypes) {
payload.training_type_ids = bulkTrainingTypeIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
}
if (bulkPatchTargetGroups) {
payload.target_group_ids = bulkTargetGroupIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
}
if (bulkVisibility === 'club') {
const manual = String(bulkClubManual || '').trim()
if (manual && /^\d+$/.test(manual)) payload.club_id = Number(manual)
else if (bulkClubSelect && /^\d+$/.test(String(bulkClubSelect))) {
payload.club_id = Number(bulkClubSelect)
}
}
setBulkSubmitting(true)
try {
const res = await api.bulkPatchExercisesMetadata(payload)
const updatedSet = new Set((res.updated || []).map((x) => Number(x)))
let resolvedClubId = null
if (bulkVisibility === 'club') {
if (payload.club_id != null) resolvedClubId = payload.club_id
else if (effectiveClubId != null && !Number.isNaN(effectiveClubId)) resolvedClubId = effectiveClubId
}
const clubLabel =
resolvedClubId != null ? clubNameById[resolvedClubId] || `Verein #${resolvedClubId}` : null
let nextPrimaryFocusName = null
if (bulkPatchFocusAreas) {
const faNums = bulkFocusAreaIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
if (faNums.length > 0) {
const opt = focusOptions.find((o) => Number(o.id) === Number(faNums[0]))
nextPrimaryFocusName = String(opt?.label ?? '').trim() || String(faNums[0])
}
}
setExercises((prev) =>
prev.map((e) => {
if (!updatedSet.has(Number(e.id))) return e
const next = { ...e }
if (bulkVisibility) {
next.visibility = bulkVisibility
next.club_id = bulkVisibility === 'club' ? resolvedClubId : null
next.club_name = bulkVisibility === 'club' ? clubLabel : null
}
if (bulkStatus) next.status = bulkStatus
if (bulkPatchFocusAreas) {
if (nextPrimaryFocusName == null) delete next.focus_area
else next.focus_area = nextPrimaryFocusName
}
return next
})
)
let msg = `${res.updated_count ?? updatedSet.size} Übung(en) aktualisiert.`
if (res.failed_count) msg += `\n${res.failed_count} nicht geändert (siehe Details).`
if (Array.isArray(res.failed) && res.failed.length) {
msg +=
'\n\n' +
res.failed
.slice(0, 12)
.map((f) => `#${f.id}: ${f.detail}`)
.join('\n')
if (res.failed.length > 12) msg += '\n…'
}
alert(msg)
setBulkModalOpen(false)
clearSelection()
} catch (err) {
alert('Massenänderung fehlgeschlagen: ' + (err.message || String(err)))
} finally {
setBulkSubmitting(false)
}
}
if (!catalogsReady && pageTab === 'list') {
return (
<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 className="exercises-page__header">
<h1 className="page-title exercises-page__title">Übungen</h1>
{pageTab === 'list' ? (
<NavStateLink
to="/exercises/new"
returnContext={exercisesModuleReturnContext}
className="btn btn-primary"
>
+ Neu
</NavStateLink>
) : (
<span aria-hidden="true" />
)}
</div>
<PageSectionNav
ariaLabel="Übungen Bereiche"
value={pageTab}
onChange={setPageTab}
items={EXERCISES_PAGE_TABS}
className="exercises-page-toolbar-tabs"
/>
{pageTab === 'progression' ? (
<Suspense
fallback={
<div className="card empty-state" style={{ padding: '2rem 1rem' }}>
<div className="spinner" />
<p className="muted" style={{ marginTop: '12px' }}>
Lade Progressionsgraphen
</p>
</div>
}
>
<ExerciseProgressionGraphPanel />
</Suspense>
) : (
<>
<ExerciseListSearchBar
searchTitleSuggestions={searchTitleSuggestions}
searchInput={searchInput}
onSearchInputChange={setSearchInput}
aiSearchInput={aiSearchInput}
onAiSearchInputChange={setAiSearchInput}
mineOnly={mineOnly}
onToggleMineOnly={() => setMineOnly((v) => !v)}
onOpenFilter={() => setFilterModalOpen(true)}
filterChips={filterChips}
onResetAllFilters={resetAllFilters}
exerciseCount={exercises.length}
allOnPageSelected={allOnPageSelected}
onToggleSelectAllPage={toggleSelectAllPage}
/>
<ExerciseListBulkToolbar
selectedCount={selectedIds.size}
bulkMaxIds={BULK_MAX_IDS}
onClearSelection={clearSelection}
onOpenBulkModal={openBulkModal}
onOpenSaveModuleModal={() => setSaveModuleModalOpen(true)}
/>
<ExerciseListFilterModal
open={filterModalOpen}
onClose={() => setFilterModalOpen(false)}
filters={filters}
setFilters={setFilters}
focusOptions={focusOptions}
styleOptions={styleOptions}
trainingTypeOptions={trainingTypeOptions}
targetGroupOptions={targetGroupOptions}
skillOptions={skillOptions}
visibilityOptions={visibilityOptions}
statusOptions={statusOptions}
savingExercisePrefs={savingExercisePrefs}
onSaveStandard={handleSaveExerciseFilterPrefs}
onResetAll={resetAllFilters}
/>
<ExerciseListBulkModal
open={bulkModalOpen}
onClose={() => setBulkModalOpen(false)}
onSubmit={handleBulkSubmit}
bulkSubmitting={bulkSubmitting}
selectedCount={selectedIds.size}
bulkMaxIds={BULK_MAX_IDS}
user={user}
isPlatformAdmin={isPlatformAdmin}
statusOptions={statusOptions}
bulkVisibilityOptions={bulkVisibilityOptions}
focusOptions={focusOptions}
styleOptions={styleOptions}
trainingTypeOptions={trainingTypeOptions}
targetGroupOptions={targetGroupOptions}
bulkVisibility={bulkVisibility}
setBulkVisibility={setBulkVisibility}
bulkStatus={bulkStatus}
setBulkStatus={setBulkStatus}
bulkClubSelect={bulkClubSelect}
setBulkClubSelect={setBulkClubSelect}
bulkClubManual={bulkClubManual}
setBulkClubManual={setBulkClubManual}
bulkPatchFocusAreas={bulkPatchFocusAreas}
setBulkPatchFocusAreas={setBulkPatchFocusAreas}
bulkFocusAreaIds={bulkFocusAreaIds}
setBulkFocusAreaIds={setBulkFocusAreaIds}
bulkPatchStyleDirections={bulkPatchStyleDirections}
setBulkPatchStyleDirections={setBulkPatchStyleDirections}
bulkStyleDirectionIds={bulkStyleDirectionIds}
setBulkStyleDirectionIds={setBulkStyleDirectionIds}
bulkPatchTrainingTypes={bulkPatchTrainingTypes}
setBulkPatchTrainingTypes={setBulkPatchTrainingTypes}
bulkTrainingTypeIds={bulkTrainingTypeIds}
setBulkTrainingTypeIds={setBulkTrainingTypeIds}
bulkPatchTargetGroups={bulkPatchTargetGroups}
setBulkPatchTargetGroups={setBulkPatchTargetGroups}
bulkTargetGroupIds={bulkTargetGroupIds}
setBulkTargetGroupIds={setBulkTargetGroupIds}
/>
<SaveSelectedExercisesAsModuleModal
open={saveModuleModalOpen}
onClose={() => setSaveModuleModalOpen(false)}
selectedExercises={selectedExercisesInListOrder}
returnContext={exercisesModuleReturnContext}
onSuccess={clearSelection}
/>
<ExercisePeekModal
open={peekExercise != null}
exerciseId={peekExercise?.id}
titleFallback={peekExercise?.title}
onClose={() => setPeekExercise(null)}
/>
{listFetching && exercises.length === 0 && selectedEntries.length === 0 ? (
<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 && selectedEntries.length === 0 ? (
<div className="card">
<p className="exercises-empty-text">Keine Übungen gefunden.</p>
</div>
) : (
<>
{selectedEntries.length > 0 ? (
<section className="exercises-selection-section" data-testid="exercises-selection-section">
<div className="exercises-selection-section__head">
<h2 className="exercises-selection-section__title">Auswahl ({selectedEntries.length})</h2>
<p className="exercises-selection-section__hint">
Bleibt sichtbar, auch wenn du den Filter wechselst ideal für die Modul-Zusammenstellung.
</p>
</div>
<div className="exercises-list-grid exercises-list-grid--selection">
{selectedExercisesDisplay.map((exercise) => (
<ExerciseListCard
key={`sel-${exercise.id}`}
exercise={exercise}
user={user}
selectedIds={selectedIds}
toggleSelect={toggleSelect}
onDelete={handleDelete}
onPeek={(ex) => setPeekExercise({ id: ex.id, title: ex.title })}
selectionPinned
/>
))}
</div>
</section>
) : null}
{filterResultExercises.length === 0 ? (
selectedEntries.length > 0 ? (
<p className="exercises-meta-line exercises-meta-line--muted">
Keine weiteren Treffer für den aktuellen Filter.
</p>
) : null
) : (
<>
{listFetching ? (
<p className="exercises-meta-line exercises-meta-line--muted">Aktualisiere Treffer</p>
) : null}
<p className="exercises-meta-line">
{filterResultExercises.length} Treffer
{selectedEntries.length > 0 ? ' (ohne bereits Ausgewählte)' : ''}
{hasMore ? ' · es gibt weitere Einträge' : ''}
</p>
<div className="exercises-list-grid" data-testid="exercises-list-grid">
{filterResultExercises.map((exercise) => (
<ExerciseListCard
key={exercise.id}
exercise={exercise}
user={user}
selectedIds={selectedIds}
toggleSelect={toggleSelect}
onDelete={handleDelete}
onPeek={(ex) => setPeekExercise({ id: ex.id, title: ex.title })}
/>
))}
</div>
{hasMore && (
<div className="exercises-load-more">
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
{loadingMore ? 'Laden…' : 'Mehr laden'}
</button>
</div>
)}
</>
)}
</>
)}
</>
)}
</div>
)
}
export default ExercisesListPageRoot