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 (
- 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). +
+ Vereins-/Trainerfilter folgen später. Fachliche Filter über „Filter“ – zwischen Feldern UND, Auswahl mehrerer Werte je Feld mit ODER.
-- Standardfilter aus Verein und Trainerrolle folgen später; die KI-Nutzung der zweiten Zeile - kommt mit einem eigenen Endpunkt. -
-+ 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). +
+