From 0d4ad9a2c83fb2458b3062fb0dac4290e7708c8c Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 28 Apr 2026 08:21:55 +0200 Subject: [PATCH] feat: implement exercise filter modal and enhance filtering logic - Added a new exercise filter modal with improved layout and styling for better user experience. - Introduced initial filter state and logic to count active filter groups, enhancing the filtering capabilities on the ExercisesListPage. - Updated CSS styles to support the new modal and improve overall UI consistency. - Implemented keyboard accessibility for closing the filter modal, enhancing usability. --- frontend/src/app.css | 61 ++++ frontend/src/pages/ExercisesListPage.jsx | 350 ++++++++++++++--------- 2 files changed, 281 insertions(+), 130 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index 5b59d53..7760c9f 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1537,6 +1537,67 @@ a.analysis-split__nav-item { overscroll-behavior: contain; } +.exercise-filter-modal.admin-modal-sheet { + max-width: min(920px, calc(100vw - 16px)); +} +.exercise-filter-modal .admin-modal-sheet__body.exercise-filter-modal__scroll { + flex: 1; + min-height: 0; +} +.exercise-filter-modal__footer { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: flex-end; + align-items: center; + padding: 12px 16px; + padding-bottom: max(12px, env(safe-area-inset-bottom, 0px)); + border-top: 1px solid var(--border); + flex-shrink: 0; + background: var(--surface); +} +.exercise-search-bar__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-top: 12px; +} +.exercise-filter-trigger { + display: inline-flex; + align-items: center; + gap: 8px; +} +.exercise-filter-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 22px; + padding: 0 6px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + background: var(--accent); + color: #fff; +} +.exercise-filters-modal-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 14px; +} +.exercise-filter-level-row { + grid-column: 1 / -1; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} +@media (max-width: 480px) { + .exercise-filter-level-row { + grid-template-columns: 1fr; + } +} + /* Reifegradmodell-Admin: klare Schritte, responsives Raster */ .admin-matrix-alert { border: 1px solid var(--danger); diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx index a47052f..72aab3b 100644 --- a/frontend/src/pages/ExercisesListPage.jsx +++ b/frontend/src/pages/ExercisesListPage.jsx @@ -1,13 +1,37 @@ -import React, { useState, useEffect, useMemo } from 'react' +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 SearchableSelect from '../components/SearchableSelect' import MultiSelectCombo from '../components/MultiSelectCombo' 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 countActiveFilterGroups(f) { + let n = 0 + if (f.focus_area_ids?.length) n++ + if (f.style_direction_ids?.length) n++ + if (f.training_type_ids?.length) n++ + if (f.target_group_ids?.length) n++ + if (f.skill_ids?.length) n++ + if (f.skill_min_level || f.skill_max_level) n++ + if (f.visibility_any?.length) n++ + if (f.status_any?.length) n++ + return n +} + function ExercisesListPage() { const [exercises, setExercises] = useState([]) const [catalogs, setCatalogs] = useState({ @@ -26,17 +50,10 @@ function ExercisesListPage() { const [aiSearchInput, setAiSearchInput] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('') const [debouncedAiSearch, setDebouncedAiSearch] = useState('') - const [filters, setFilters] = useState({ - focus_area_ids: [], - style_direction_ids: [], - training_type_ids: [], - target_group_ids: [], - skill_ids: [], - skill_min_level: '', - skill_max_level: '', - visibility_any: [], - status_any: [], - }) + const [filters, setFilters] = useState(() => ({ ...INITIAL_FILTERS })) + const [filterModalOpen, setFilterModalOpen] = useState(false) + + const activeFilterGroups = useMemo(() => countActiveFilterGroups(filters), [filters]) useEffect(() => { const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400) @@ -48,6 +65,15 @@ function ExercisesListPage() { 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) => ({ @@ -72,10 +98,6 @@ function ExercisesListPage() { () => catalogs.skills.map((s) => ({ id: s.id, label: s.name || String(s.id) })), [catalogs.skills] ) - const levelFilterOptions = useMemo( - () => LEVEL_FILTER_OPTS.map((o) => ({ id: o.level, label: o.label })), - [] - ) const visibilityOptions = useMemo( () => [ { id: 'private', label: 'Privat' }, @@ -204,6 +226,8 @@ function ExercisesListPage() { } } + const resetAllFilters = useCallback(() => setFilters({ ...INITIAL_FILTERS }), []) + if (!catalogsReady || loading) { return (
@@ -231,122 +255,188 @@ function ExercisesListPage() {
-
-

- Filter untereinander werden mit UND kombiniert. Mehrere Einträge in einem Feld mit{' '} - ODER (die Übung muss mindestens eine der gewählten Zuordnungen erfüllen). +

+ + setSearchInput(e.target.value)} + autoComplete="off" + style={{ marginBottom: '10px' }} + /> + + setAiSearchInput(e.target.value)} + autoComplete="off" + /> +
+ + {activeFilterGroups > 0 ? ( + + ) : null} +
+

+ Vereins-/Trainerfilter folgen später. Fachliche Filter über „Filter“ – zwischen Feldern UND, Auswahl mehrerer Werte je Feld mit ODER.

-
- - setSearchInput(e.target.value)} - autoComplete="off" - /> -
-
- - setAiSearchInput(e.target.value)} - autoComplete="off" - /> -

- Standardfilter aus Verein und Trainerrolle folgen später; die KI-Nutzung der zweiten Zeile - kommt mit einem eigenen Endpunkt. -

-
-
- - setFilters({ ...filters, focus_area_ids: v })} - options={focusOptions} - placeholder="Fokus suchen oder „▼ Alle“ …" - /> -
-
- - setFilters({ ...filters, style_direction_ids: v })} - options={styleOptions} - placeholder="Stilrichtung suchen …" - /> -
-
- - setFilters({ ...filters, training_type_ids: v })} - options={trainingTypeOptions} - placeholder="Trainingsstil suchen …" - /> -
-
- - setFilters({ ...filters, target_group_ids: v })} - options={targetGroupOptions} - placeholder="Zielgruppe suchen …" - /> -
-
- - setFilters({ ...filters, skill_ids: v })} - options={skillOptions} - placeholder="Fähigkeit suchen …" - /> -
-
- - setFilters({ ...filters, skill_min_level: v })} - options={levelFilterOptions} - allLabel="egal (min)" - filterPlaceholder="Stufe suchen…" - /> -
-
- - setFilters({ ...filters, skill_max_level: v })} - options={levelFilterOptions} - allLabel="egal (max)" - filterPlaceholder="Stufe suchen…" - /> -
-
- - setFilters({ ...filters, visibility_any: v })} - options={visibilityOptions} - placeholder="Sichtbarkeit wählen …" - /> -
-
- - setFilters({ ...filters, status_any: v })} - options={statusOptions} - placeholder="Status wählen …" - /> -
+ {filterModalOpen && ( +
{ + if (e.target === e.currentTarget) setFilterModalOpen(false) + }} + > +
e.stopPropagation()} + > +
+

+ Übungen filtern +

+ +
+
+

+ Zwischen den Bereichen gilt UND. Innerhalb eines Bereichs werden mehrere Einträge mit{' '} + ODER verknüpft (die Übung muss mindestens eine gewählte Zuordnung erfüllen). +

+
+
+ + setFilters({ ...filters, focus_area_ids: v })} + options={focusOptions} + placeholder="Fokus suchen oder „▼ Alle“ …" + /> +
+
+ + setFilters({ ...filters, style_direction_ids: v })} + options={styleOptions} + placeholder="Stilrichtung suchen …" + /> +
+
+ + setFilters({ ...filters, training_type_ids: v })} + options={trainingTypeOptions} + placeholder="Trainingsstil suchen …" + /> +
+
+ + setFilters({ ...filters, target_group_ids: v })} + options={targetGroupOptions} + placeholder="Zielgruppe suchen …" + /> +
+
+ + setFilters({ ...filters, skill_ids: v })} + options={skillOptions} + placeholder="Fähigkeit suchen …" + /> +
+
+
+ + +
+
+ + +
+
+
+ + setFilters({ ...filters, visibility_any: v })} + options={visibilityOptions} + placeholder="Sichtbarkeit wählen …" + /> +
+
+ + setFilters({ ...filters, status_any: v })} + options={statusOptions} + placeholder="Status wählen …" + /> +
+
+
+
+ + +
+
+
+ )} + {exercises.length === 0 ? (