- Removed constrained width classes from multiple pages to allow full-width layout, enhancing adaptability on larger screens. - Updated app.css to eliminate unnecessary max-width properties, ensuring a more fluid design across various components. - Adjusted styles in Navigation and other pages for consistent full-width presentation, improving user experience on diverse devices.
732 lines
26 KiB
JavaScript
732 lines
26 KiB
JavaScript
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
||
import { Link } from 'react-router-dom'
|
||
import api from '../utils/api'
|
||
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
|
||
import MultiSelectCombo from '../components/MultiSelectCombo'
|
||
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
|
||
|
||
const PAGE_SIZE = 100
|
||
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
|
||
|
||
const INITIAL_FILTERS = {
|
||
focus_area_ids: [],
|
||
style_direction_ids: [],
|
||
training_type_ids: [],
|
||
target_group_ids: [],
|
||
skill_ids: [],
|
||
skill_min_level: '',
|
||
skill_max_level: '',
|
||
visibility_any: [],
|
||
status_any: [],
|
||
}
|
||
|
||
function levelOptionShort(levelStr) {
|
||
const o = LEVEL_FILTER_OPTS.find((x) => String(x.level) === String(levelStr))
|
||
return o ? String(o.level) : String(levelStr)
|
||
}
|
||
|
||
function ExercisesListPage() {
|
||
const [exercises, setExercises] = useState([])
|
||
const [catalogs, setCatalogs] = useState({
|
||
focusAreas: [],
|
||
styleDirections: [],
|
||
trainingTypes: [],
|
||
targetGroups: [],
|
||
skills: [],
|
||
})
|
||
const [catalogsReady, setCatalogsReady] = useState(false)
|
||
const [listFetching, setListFetching] = useState(false)
|
||
const [loadingMore, setLoadingMore] = useState(false)
|
||
const [offset, setOffset] = useState(0)
|
||
const [hasMore, setHasMore] = useState(false)
|
||
const [searchInput, setSearchInput] = useState('')
|
||
const [aiSearchInput, setAiSearchInput] = useState('')
|
||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||
const [debouncedAiSearch, setDebouncedAiSearch] = useState('')
|
||
const [filters, setFilters] = useState(() => ({ ...INITIAL_FILTERS }))
|
||
const [filterModalOpen, setFilterModalOpen] = useState(false)
|
||
const [pageTab, setPageTab] = useState('list')
|
||
|
||
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 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(() => {
|
||
const chips = []
|
||
|
||
;(filters.focus_area_ids || []).forEach((id) => {
|
||
const opt = focusOptions.find((o) => String(o.id) === String(id))
|
||
chips.push({
|
||
key: `fa-${id}`,
|
||
label: `Fokus: ${opt?.label ?? id}`,
|
||
onRemove: () =>
|
||
setFilters((prev) => ({
|
||
...prev,
|
||
focus_area_ids: prev.focus_area_ids.filter((x) => String(x) !== String(id)),
|
||
})),
|
||
})
|
||
})
|
||
;(filters.style_direction_ids || []).forEach((id) => {
|
||
const opt = styleOptions.find((o) => String(o.id) === String(id))
|
||
chips.push({
|
||
key: `sd-${id}`,
|
||
label: `Stil: ${opt?.label ?? id}`,
|
||
onRemove: () =>
|
||
setFilters((prev) => ({
|
||
...prev,
|
||
style_direction_ids: prev.style_direction_ids.filter((x) => String(x) !== String(id)),
|
||
})),
|
||
})
|
||
})
|
||
;(filters.training_type_ids || []).forEach((id) => {
|
||
const opt = trainingTypeOptions.find((o) => String(o.id) === String(id))
|
||
chips.push({
|
||
key: `tt-${id}`,
|
||
label: `Trainingsstil: ${opt?.label ?? id}`,
|
||
onRemove: () =>
|
||
setFilters((prev) => ({
|
||
...prev,
|
||
training_type_ids: prev.training_type_ids.filter((x) => String(x) !== String(id)),
|
||
})),
|
||
})
|
||
})
|
||
;(filters.target_group_ids || []).forEach((id) => {
|
||
const opt = targetGroupOptions.find((o) => String(o.id) === String(id))
|
||
chips.push({
|
||
key: `tg-${id}`,
|
||
label: `Zielgruppe: ${opt?.label ?? id}`,
|
||
onRemove: () =>
|
||
setFilters((prev) => ({
|
||
...prev,
|
||
target_group_ids: prev.target_group_ids.filter((x) => String(x) !== String(id)),
|
||
})),
|
||
})
|
||
})
|
||
;(filters.skill_ids || []).forEach((id) => {
|
||
const opt = skillOptions.find((o) => String(o.id) === String(id))
|
||
chips.push({
|
||
key: `sk-${id}`,
|
||
label: `Fähigkeit: ${opt?.label ?? id}`,
|
||
onRemove: () =>
|
||
setFilters((prev) => ({
|
||
...prev,
|
||
skill_ids: prev.skill_ids.filter((x) => String(x) !== String(id)),
|
||
})),
|
||
})
|
||
})
|
||
|
||
if (filters.skill_min_level || filters.skill_max_level) {
|
||
const a = filters.skill_min_level ? levelOptionShort(filters.skill_min_level) : '…'
|
||
const b = filters.skill_max_level ? levelOptionShort(filters.skill_max_level) : '…'
|
||
chips.push({
|
||
key: 'skill-levels',
|
||
label: `Stufe ${a}–${b}`,
|
||
onRemove: () =>
|
||
setFilters((prev) => ({
|
||
...prev,
|
||
skill_min_level: '',
|
||
skill_max_level: '',
|
||
})),
|
||
})
|
||
}
|
||
|
||
;(filters.visibility_any || []).forEach((id) => {
|
||
const opt = visibilityOptions.find((o) => String(o.id) === String(id))
|
||
chips.push({
|
||
key: `vis-${id}`,
|
||
label: `Sichtbarkeit: ${opt?.label ?? id}`,
|
||
onRemove: () =>
|
||
setFilters((prev) => ({
|
||
...prev,
|
||
visibility_any: prev.visibility_any.filter((x) => String(x) !== String(id)),
|
||
})),
|
||
})
|
||
})
|
||
;(filters.status_any || []).forEach((id) => {
|
||
const opt = statusOptions.find((o) => String(o.id) === String(id))
|
||
chips.push({
|
||
key: `st-${id}`,
|
||
label: `Status: ${opt?.label ?? id}`,
|
||
onRemove: () =>
|
||
setFilters((prev) => ({
|
||
...prev,
|
||
status_any: prev.status_any.filter((x) => String(x) !== String(id)),
|
||
})),
|
||
})
|
||
})
|
||
|
||
return chips
|
||
}, [
|
||
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 queryBase = useMemo(() => {
|
||
const q = {}
|
||
const n = (v) => (v === '' || v == null ? undefined : Number(v))
|
||
const ids = (arr) =>
|
||
Array.isArray(arr) && arr.length ? arr.map((x) => Number(x)).filter((x) => !Number.isNaN(x)) : undefined
|
||
const fa = ids(filters.focus_area_ids)
|
||
if (fa?.length) q.focus_area_ids = fa
|
||
const sd = ids(filters.style_direction_ids)
|
||
if (sd?.length) q.style_direction_ids = sd
|
||
const tt = ids(filters.training_type_ids)
|
||
if (tt?.length) q.training_type_ids = tt
|
||
const tg = ids(filters.target_group_ids)
|
||
if (tg?.length) q.target_group_ids = tg
|
||
const sk = ids(filters.skill_ids)
|
||
if (sk?.length) q.skill_ids = sk
|
||
if (filters.skill_min_level) q.skill_min_level = n(filters.skill_min_level)
|
||
if (filters.skill_max_level) q.skill_max_level = n(filters.skill_max_level)
|
||
if (filters.visibility_any?.length) q.visibility_any = [...filters.visibility_any]
|
||
if (filters.status_any?.length) q.status_any = [...filters.status_any]
|
||
if (debouncedSearch) q.search = debouncedSearch
|
||
if (debouncedAiSearch) q.ai_search = debouncedAiSearch
|
||
return q
|
||
}, [filters, debouncedSearch, debouncedAiSearch])
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
;(async () => {
|
||
try {
|
||
const [fa, sd, tt, tg, sk] = await Promise.all([
|
||
api.listFocusAreas(),
|
||
api.listStyleDirections(),
|
||
api.listTrainingTypes(),
|
||
api.listTargetGroups(),
|
||
api.listSkills(),
|
||
])
|
||
if (!cancelled) {
|
||
setCatalogs({
|
||
focusAreas: fa,
|
||
styleDirections: sd,
|
||
trainingTypes: tt,
|
||
targetGroups: tg,
|
||
skills: sk,
|
||
})
|
||
setCatalogsReady(true)
|
||
}
|
||
} catch (err) {
|
||
if (!cancelled) {
|
||
console.error(err)
|
||
alert('Kataloge konnten nicht geladen werden: ' + err.message)
|
||
setCatalogsReady(true)
|
||
}
|
||
}
|
||
})()
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (!catalogsReady || pageTab !== 'list') return
|
||
let cancelled = false
|
||
const run = async () => {
|
||
setListFetching(true)
|
||
setOffset(0)
|
||
try {
|
||
const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset: 0 })
|
||
if (cancelled) return
|
||
setExercises(batch)
|
||
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) setListFetching(false)
|
||
}
|
||
}
|
||
run()
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [queryBase, catalogsReady, pageTab])
|
||
|
||
const loadMore = async () => {
|
||
if (loadingMore || !hasMore) return
|
||
setLoadingMore(true)
|
||
try {
|
||
const batch = await api.listExercises({ ...queryBase, 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)
|
||
}
|
||
}
|
||
|
||
const resetAllFilters = useCallback(() => setFilters({ ...INITIAL_FILTERS }), [])
|
||
|
||
if (!catalogsReady && pageTab === 'list') {
|
||
return (
|
||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||
<div className="spinner"></div>
|
||
<p>Lade Kataloge…</p>
|
||
</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>
|
||
{pageTab === 'list' ? (
|
||
<Link to="/exercises/new" className="btn btn-primary">
|
||
+ Neu
|
||
</Link>
|
||
) : (
|
||
<span />
|
||
)}
|
||
</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>
|
||
|
||
{pageTab === 'progression' ? (
|
||
<ExerciseProgressionGraphPanel />
|
||
) : (
|
||
<>
|
||
<div className="card exercise-search-bar" style={{ marginBottom: '12px' }}>
|
||
<label className="form-label">Volltextsuche (Titel, Ziel, …)</label>
|
||
<datalist id="exercise-search-titles">
|
||
{searchTitleSuggestions.map((t) => (
|
||
<option key={t} value={t} />
|
||
))}
|
||
</datalist>
|
||
<input
|
||
type="search"
|
||
className="form-input"
|
||
placeholder="Suchbegriffe…"
|
||
value={searchInput}
|
||
onChange={(e) => setSearchInput(e.target.value)}
|
||
autoComplete="on"
|
||
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
|
||
type="search"
|
||
className="form-input"
|
||
placeholder="zweiter Begriff — zusätzliche Volltextsuche (ODER)"
|
||
value={aiSearchInput}
|
||
onChange={(e) => setAiSearchInput(e.target.value)}
|
||
autoComplete="on"
|
||
name="exercise-ai-search"
|
||
list="exercise-search-titles"
|
||
enterKeyHint="search"
|
||
/>
|
||
<div className="exercise-search-bar__actions">
|
||
<button type="button" className="btn btn-secondary exercise-filter-trigger" onClick={() => setFilterModalOpen(true)}>
|
||
Filter
|
||
{filterChips.length > 0 ? (
|
||
<span className="exercise-filter-badge" aria-hidden>
|
||
{filterChips.length}
|
||
</span>
|
||
) : null}
|
||
</button>
|
||
{filterChips.length > 0 ? (
|
||
<button type="button" className="btn" onClick={resetAllFilters}>
|
||
Alle entfernen
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
{filterChips.length > 0 ? (
|
||
<div className="exercise-filter-chips-row" role="list" aria-label="Aktive Filter">
|
||
{filterChips.map((c) => (
|
||
<button
|
||
key={c.key}
|
||
type="button"
|
||
role="listitem"
|
||
className="exercise-filter-chip"
|
||
title="Filter entfernen"
|
||
onClick={() => c.onRemove()}
|
||
>
|
||
<span className="exercise-filter-chip__text">{c.label}</span>
|
||
<span className="exercise-filter-chip__x" aria-hidden>
|
||
×
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '10px', marginBottom: 0 }}>
|
||
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.
|
||
</p>
|
||
</div>
|
||
|
||
{filterModalOpen && (
|
||
<div
|
||
className="admin-modal-backdrop"
|
||
role="presentation"
|
||
onClick={(e) => {
|
||
if (e.target === e.currentTarget) setFilterModalOpen(false)
|
||
}}
|
||
>
|
||
<div
|
||
className="admin-modal-sheet exercise-filter-modal"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="exercise-filter-modal-title"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="admin-modal-sheet__header">
|
||
<h3 id="exercise-filter-modal-title" className="admin-modal-sheet__title">
|
||
Übungen filtern
|
||
</h3>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary admin-modal-sheet__close"
|
||
onClick={() => setFilterModalOpen(false)}
|
||
>
|
||
Schließen
|
||
</button>
|
||
</div>
|
||
<div className="admin-modal-sheet__body exercise-filter-modal__scroll">
|
||
<p style={{ fontSize: '13px', color: 'var(--text2)', 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>
|
||
|
||
<section className="exercise-filter-section">
|
||
<h4 className="exercise-filter-section-title">Zuordnung</h4>
|
||
<div className="exercise-filters-modal-grid">
|
||
<div>
|
||
<label className="form-label">Fokus</label>
|
||
<MultiSelectCombo
|
||
value={filters.focus_area_ids}
|
||
onChange={(v) => setFilters({ ...filters, focus_area_ids: v })}
|
||
options={focusOptions}
|
||
placeholder="Fokus suchen oder „▼ Alle“ …"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="form-label">Stilrichtung</label>
|
||
<MultiSelectCombo
|
||
value={filters.style_direction_ids}
|
||
onChange={(v) => setFilters({ ...filters, style_direction_ids: v })}
|
||
options={styleOptions}
|
||
placeholder="Stilrichtung suchen …"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="form-label">Trainingsstil</label>
|
||
<MultiSelectCombo
|
||
value={filters.training_type_ids}
|
||
onChange={(v) => setFilters({ ...filters, training_type_ids: v })}
|
||
options={trainingTypeOptions}
|
||
placeholder="Trainingsstil suchen …"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="form-label">Zielgruppe</label>
|
||
<MultiSelectCombo
|
||
value={filters.target_group_ids}
|
||
onChange={(v) => setFilters({ ...filters, target_group_ids: v })}
|
||
options={targetGroupOptions}
|
||
placeholder="Zielgruppe suchen …"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="exercise-filter-section">
|
||
<h4 className="exercise-filter-section-title">Fähigkeit und zugehörige Stufe</h4>
|
||
<div className="exercise-filter-skill-block">
|
||
<label className="form-label">Fähigkeit</label>
|
||
<MultiSelectCombo
|
||
value={filters.skill_ids}
|
||
onChange={(v) => setFilters({ ...filters, skill_ids: v })}
|
||
options={skillOptions}
|
||
placeholder="Fähigkeit suchen …"
|
||
/>
|
||
<p className="exercise-filter-skill-hint">
|
||
Die Stufen filtern nach dem Niveau der Zuordnung Übung ↔ Fähigkeit (von–bis).
|
||
</p>
|
||
<div className="exercise-filter-skill-levels-row">
|
||
<label className="exercise-filter-skill-level-field">
|
||
<span className="exercise-filter-skill-level-caption">von</span>
|
||
<select
|
||
className="form-input exercise-filter-level-select"
|
||
title="Mindest-Stufe"
|
||
value={filters.skill_min_level}
|
||
onChange={(e) => setFilters({ ...filters, skill_min_level: e.target.value })}
|
||
>
|
||
<option value="">–</option>
|
||
{LEVEL_FILTER_OPTS.map((o) => (
|
||
<option key={o.value} value={String(o.level)} title={o.label}>
|
||
{o.level}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<span className="exercise-filter-skill-dash" aria-hidden>
|
||
–
|
||
</span>
|
||
<label className="exercise-filter-skill-level-field">
|
||
<span className="exercise-filter-skill-level-caption">bis</span>
|
||
<select
|
||
className="form-input exercise-filter-level-select"
|
||
title="Höchst-Stufe"
|
||
value={filters.skill_max_level}
|
||
onChange={(e) => setFilters({ ...filters, skill_max_level: e.target.value })}
|
||
>
|
||
<option value="">–</option>
|
||
{LEVEL_FILTER_OPTS.map((o) => (
|
||
<option key={`m-${o.value}`} value={String(o.level)} title={o.label}>
|
||
{o.level}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="exercise-filter-section exercise-filter-section--last">
|
||
<h4 className="exercise-filter-section-title">Freigabe</h4>
|
||
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two">
|
||
<div>
|
||
<label className="form-label">Sichtbarkeit</label>
|
||
<MultiSelectCombo
|
||
value={filters.visibility_any}
|
||
onChange={(v) => setFilters({ ...filters, visibility_any: v })}
|
||
options={visibilityOptions}
|
||
placeholder="Sichtbarkeit wählen …"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="form-label">Status</label>
|
||
<MultiSelectCombo
|
||
value={filters.status_any}
|
||
onChange={(v) => setFilters({ ...filters, status_any: v })}
|
||
options={statusOptions}
|
||
placeholder="Status wählen …"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
<div className="exercise-filter-modal__footer">
|
||
<button type="button" className="btn" onClick={resetAllFilters}>
|
||
Alle Filter zurücksetzen
|
||
</button>
|
||
<button type="button" className="btn btn-primary" onClick={() => setFilterModalOpen(false)}>
|
||
Fertig
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{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>
|
||
) : exercises.length === 0 ? (
|
||
<div className="card">
|
||
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
||
Keine Übungen gefunden.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{listFetching ? (
|
||
<p style={{ fontSize: '13px', color: 'var(--text3)', marginBottom: '8px' }}>Aktualisiere Treffer…</p>
|
||
) : null}
|
||
<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
|