System-Information
diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx
index 38603ec..5bc40ca 100644
--- a/frontend/src/pages/ExercisesListPage.jsx
+++ b/frontend/src/pages/ExercisesListPage.jsx
@@ -1,25 +1,121 @@
-import React, { useState, useEffect, useMemo, useCallback } from 'react'
+import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
import { Link } from 'react-router-dom'
+import {
+ Eye,
+ Pencil,
+ Trash2,
+ Globe,
+ Users,
+ Lock,
+ CheckCircle2,
+ Archive,
+ CircleDot,
+ FilePenLine,
+} from 'lucide-react'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import MultiSelectCombo from '../components/MultiSelectCombo'
+import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker'
+import CatalogRulePicker from '../components/CatalogRulePicker'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
+import PageSectionNav from '../components/PageSectionNav'
+import {
+ INITIAL_EXERCISE_LIST_FILTERS,
+ mergeExerciseListPrefsFromApi,
+ compactExerciseListPrefsPayload,
+ splitMnCatalogRules,
+ splitScalarCatalogRules,
+} from '../constants/exerciseListFilters'
+import { sanitizeExerciseRichText, coerceApiNameList } from '../utils/sanitizeHtml'
+import { canUserRequestExerciseDelete } from '../utils/exercisePermissions'
const PAGE_SIZE = 100
const BULK_MAX_IDS = 500
+const EXERCISES_PAGE_TABS = [
+ { id: 'list', label: 'Liste' },
+ { id: 'progression', label: 'Progressionsgraphen' },
+]
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: [],
+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 pushCatalogRuleFilterChips(chips, field, rules, options, topicLabel, setFilters) {
+ ;(rules || []).forEach((r) => {
+ const rid = String(r.id ?? r.focus_area_id ?? '')
+ const opt = options.find((o) => String(o.id) === rid)
+ chips.push({
+ key: `${field}-${r.key}`,
+ label: `${topicLabel}: ${r.mode === 'forbid' ? '−' : '+'} ${opt?.label ?? rid}`,
+ onRemove: () =>
+ setFilters((prev) => ({
+ ...prev,
+ [field]: (prev[field] || []).filter((x) => x.key !== r.key),
+ })),
+ })
+ })
+}
+
+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 (
+
+
+
+
+
+ ·
+
+
+
+
+
+ )
}
function levelOptionShort(levelStr) {
@@ -28,7 +124,7 @@ function levelOptionShort(levelStr) {
}
function ExercisesListPage() {
- const { user } = useAuth()
+ const { user, checkAuth } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const [exercises, setExercises] = useState([])
@@ -48,9 +144,11 @@ function ExercisesListPage() {
const [aiSearchInput, setAiSearchInput] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [debouncedAiSearch, setDebouncedAiSearch] = useState('')
- const [filters, setFilters] = useState(() => ({ ...INITIAL_FILTERS }))
+ 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 [selectedIds, setSelectedIds] = useState(() => new Set())
const [bulkModalOpen, setBulkModalOpen] = useState(false)
@@ -59,6 +157,25 @@ function ExercisesListPage() {
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([])
+
+ useEffect(() => {
+ if (!user?.id) return
+ if (prefsAppliedRef.current) return
+ setFilters(mergeExerciseListPrefsFromApi(user.exercise_list_prefs))
+ prefsAppliedRef.current = true
+ }, [user?.id, user?.exercise_list_prefs])
+
+ useEffect(() => {
+ if (!user?.id) prefsAppliedRef.current = false
+ }, [user?.id])
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
@@ -124,11 +241,21 @@ function ExercisesListPage() {
const filterChips = useMemo(() => {
const chips = []
+ pushCatalogRuleFilterChips(chips, 'focus_rules', filters.focus_rules, focusOptions, 'Fokus', setFilters)
+
+ if (filters.focus_only_without) {
+ chips.push({
+ key: 'focus-only-none',
+ label: 'Nur ohne Fokusbereich',
+ onRemove: () => setFilters((prev) => ({ ...prev, focus_only_without: false })),
+ })
+ }
+
;(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}`,
+ label: `Fokus (ODER, älter): ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
@@ -136,11 +263,37 @@ function ExercisesListPage() {
})),
})
})
+
+ pushCatalogRuleFilterChips(
+ chips,
+ 'style_direction_rules',
+ filters.style_direction_rules,
+ styleOptions,
+ 'Stil',
+ setFilters
+ )
+ pushCatalogRuleFilterChips(
+ chips,
+ 'training_type_rules',
+ filters.training_type_rules,
+ trainingTypeOptions,
+ 'Trainingsstil',
+ setFilters
+ )
+ pushCatalogRuleFilterChips(
+ chips,
+ 'target_group_rules',
+ filters.target_group_rules,
+ targetGroupOptions,
+ 'Zielgruppe',
+ setFilters
+ )
+
;(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}`,
+ label: `Stil (ODER, älter): ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
@@ -152,7 +305,7 @@ function ExercisesListPage() {
const opt = trainingTypeOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `tt-${id}`,
- label: `Trainingsstil: ${opt?.label ?? id}`,
+ label: `Trainingsstil (ODER, älter): ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
@@ -164,7 +317,7 @@ function ExercisesListPage() {
const opt = targetGroupOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `tg-${id}`,
- label: `Zielgruppe: ${opt?.label ?? id}`,
+ label: `Zielgruppe (ODER, älter): ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
@@ -172,6 +325,7 @@ function ExercisesListPage() {
})),
})
})
+
;(filters.skill_ids || []).forEach((id) => {
const opt = skillOptions.find((o) => String(o.id) === String(id))
chips.push({
@@ -200,30 +354,30 @@ function ExercisesListPage() {
})
}
- ;(filters.visibility_any || []).forEach((id) => {
- const opt = visibilityOptions.find((o) => String(o.id) === String(id))
+ pushCatalogRuleFilterChips(
+ chips,
+ 'visibility_rules',
+ filters.visibility_rules,
+ visibilityOptions,
+ 'Sichtbarkeit',
+ setFilters
+ )
+ pushCatalogRuleFilterChips(chips, 'status_rules', filters.status_rules, statusOptions, 'Status', setFilters)
+
+ if (filters.exclude_without_focus) {
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)),
- })),
+ key: 'ex-no-focus',
+ label: 'Ohne Fokus ausblenden',
+ onRemove: () => setFilters((prev) => ({ ...prev, exclude_without_focus: false })),
})
- })
- ;(filters.status_any || []).forEach((id) => {
- const opt = statusOptions.find((o) => String(o.id) === String(id))
+ }
+ if (filters.include_archived) {
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)),
- })),
+ key: 'inc-arch',
+ label: 'Archivierte anzeigen',
+ onRemove: () => setFilters((prev) => ({ ...prev, include_archived: false })),
})
- })
+ }
return chips
}, [
@@ -235,6 +389,7 @@ function ExercisesListPage() {
skillOptions,
visibilityOptions,
statusOptions,
+ setFilters,
])
/** Für Browser-/datalist-Vorschläge (aktuelle Treffer-Titel, begrenzt) */
@@ -248,20 +403,46 @@ function ExercisesListPage() {
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 fMn = splitMnCatalogRules(filters.focus_rules)
+ if (fMn.includeIds.length) q.focus_area_must_include_ids = fMn.includeIds
+ if (fMn.excludeIds.length) q.focus_area_must_exclude_ids = fMn.excludeIds
+ if (filters.focus_only_without) q.focus_only_without_focus_areas = true
+
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 sdMn = splitMnCatalogRules(filters.style_direction_rules)
+ if (sdMn.includeIds.length) q.style_direction_must_include_ids = sdMn.includeIds
+ if (sdMn.excludeIds.length) q.style_direction_must_exclude_ids = sdMn.excludeIds
+ const sdLegacy = ids(filters.style_direction_ids)
+ if (sdLegacy?.length) q.style_direction_ids = sdLegacy
+
+ const ttMn = splitMnCatalogRules(filters.training_type_rules)
+ if (ttMn.includeIds.length) q.training_type_must_include_ids = ttMn.includeIds
+ if (ttMn.excludeIds.length) q.training_type_must_exclude_ids = ttMn.excludeIds
+ const ttLegacy = ids(filters.training_type_ids)
+ if (ttLegacy?.length) q.training_type_ids = ttLegacy
+
+ const tgMn = splitMnCatalogRules(filters.target_group_rules)
+ if (tgMn.includeIds.length) q.target_group_must_include_ids = tgMn.includeIds
+ if (tgMn.excludeIds.length) q.target_group_must_exclude_ids = tgMn.excludeIds
+ const tgLegacy = ids(filters.target_group_ids)
+ if (tgLegacy?.length) q.target_group_ids = tgLegacy
+
+ const visMn = splitScalarCatalogRules(filters.visibility_rules)
+ if (visMn.includeVals.length) q.visibility_any = visMn.includeVals
+ if (visMn.excludeVals.length) q.visibility_exclude_any = visMn.excludeVals
+
+ const stMn = splitScalarCatalogRules(filters.status_rules)
+ if (stMn.includeVals.length) q.status_any = stMn.includeVals
+ if (stMn.excludeVals.length) q.status_exclude_any = stMn.excludeVals
+
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 (filters.exclude_without_focus) q.exclude_without_focus = true
+ if (filters.include_archived) q.include_archived = true
if (debouncedSearch) q.search = debouncedSearch
if (debouncedAiSearch) q.ai_search = debouncedAiSearch
return q
@@ -414,19 +595,53 @@ function ExercisesListPage() {
}
}
- const resetAllFilters = useCallback(() => setFilters({ ...INITIAL_FILTERS }), [])
+ const resetAllFilters = useCallback(() => 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 () => {
- if (!bulkVisibility && !bulkStatus) {
- alert('Bitte mindestens Sichtbarkeit oder Status wählen (nicht „nicht ändern“ bei beiden).')
+ 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)
@@ -441,6 +656,18 @@ function ExercisesListPage() {
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)
@@ -460,6 +687,15 @@ function ExercisesListPage() {
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
@@ -470,6 +706,10 @@ function ExercisesListPage() {
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
})
)
@@ -497,65 +737,43 @@ function ExercisesListPage() {
if (!catalogsReady && pageTab === 'list') {
return (
-
-
-
Lade Kataloge…
+
)
}
return (
-
-
Übungen
+
+
Übungen
{pageTab === 'list' ? (
+ Neu
) : (
-
+
)}
-
-
-
-
+
{pageTab === 'progression' ? (
) : (
<>
-
+
{selectedIds.size > 0 ? (
-
+
{selectedIds.size} ausgewählt
-
-
- Zwischen den Bereichen gilt UND. Innerhalb eines Feldes werden mehrere Einträge mit{' '}
- ODER verknüpft.
+
+ Zwischen den Bereichen gilt UND. Fokusbereiche: mehrere „+ mit“ bedeuten alle müssen
+ gesetzt sein; „− ohne“ schließt Übungen aus, die diesen Fokus zusätzlich haben. Stilrichtung /
+ Trainingsstil / Zielgruppe: mehrere „+“ = alle zutreffend (UND); „−“ verbietet die Zuordnung. Unter
+ „Freigabe“: Sichtbarkeit / Status mit „+“ = eine davon (ODER); „−“ blendet aus.
Zuordnung
-
-
-
- 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((prev) => ({ ...prev, ...patch }))}
+ />
+
+ setFilters((prev) => ({ ...prev, ...patch }))}
+ />
+ setFilters((prev) => ({ ...prev, ...patch }))}
+ />
+ setFilters((prev) => ({ ...prev, ...patch }))}
+ />
@@ -788,31 +996,77 @@ function ExercisesListPage() {
+
+
Freigabe
-
-
-
- setFilters({ ...filters, visibility_any: v })}
- options={visibilityOptions}
- placeholder="Sichtbarkeit wählen …"
- />
-
-
-
- setFilters({ ...filters, status_any: v })}
- options={statusOptions}
- placeholder="Status wählen …"
- />
-
+
+ Pro Übung nur ein Wert: mehrere „+“ bedeuten „eine davon“ (ODER). „−“ blendet Werte aus.
+
+
+ setFilters((prev) => ({ ...prev, ...patch }))}
+ />
+ setFilters((prev) => ({ ...prev, ...patch }))}
+ />
+
+ {savingExercisePrefs ? 'Speichern…' : 'Als Standard speichern'}
+
Alle Filter zurücksetzen
@@ -841,7 +1095,7 @@ function ExercisesListPage() {
>
- Massenänderung: Sichtbarkeit / Status
+ Massenänderung
-
+
Es werden {selectedIds.size} Übung(en) aus der aktuellen Auswahl bearbeitet. Pro Durchlauf
höchstens {BULK_MAX_IDS}. Ohne Berechtigung bleiben Einzelübungen unverändert (siehe Hinweis nach dem
Speichern).
+
+ Unter „Zuordnung ersetzen“: die gewählte Liste ersetzt die bisherige Zuordnung bei allen betroffenen
+ Übungen vollständig (leere Auswahl = alle Zuordnungen dieser Kategorie entfernen). Die erste Auswahl gilt
+ als Primärzuordnung.
+
+
+
+ Zuordnung (optional)
+
+
-
+
-
- Lade Übungen…
+
) : exercises.length === 0 ? (
-
- Keine Übungen gefunden.
-
+
Keine Übungen gefunden.
) : (
<>
{listFetching ? (
- Aktualisiere Treffer…
+ Aktualisiere Treffer…
) : null}
-
+
{exercises.length} angezeigt
{hasMore ? ' · es gibt weitere Einträge' : ''}
-
- {exercises.map((exercise) => (
-
-
-
toggleSelect(exercise.id)}
- aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`}
- style={{ marginTop: '4px', flexShrink: 0 }}
- />
-
-
+
+ {exercises.map((exercise) => {
+ const focusNames = exerciseFocusNames(exercise)
+ const styleNames = coerceApiNameList(exercise.style_direction_names)
+ const typeNames = coerceApiNameList(exercise.training_type_names)
+ const summaryHtml = exercise.summary
+ ? sanitizeExerciseRichText(exercise.summary)
+ : ''
+ return (
+
+
+
toggleSelect(exercise.id)}
+ aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`}
+ className="exercise-card-layout__check"
+ />
+
+
+
+ {exercise.title}
+
+
+
+ {focusNames.map((name) => (
+ {name}
+ ))}
+ {styleNames.map((name) => (
+ {name}
+ ))}
+ {typeNames.map((name) => (
+ {name}
+ ))}
+
+ {summaryHtml ? (
+
+ ) : null}
+
+
+
+
+
- {exercise.title}
+
-
-
- {exercise.focus_area && (
- {exercise.focus_area}
- )}
- {exercise.visibility}
- {exercise.status}
-
- {exercise.summary && (
-
- {exercise.summary.length > 160
- ? `${exercise.summary.slice(0, 160)}…`
- : exercise.summary}
-
- )}
+
+
+
+ {canUserRequestExerciseDelete(user, exercise) ? (
+
handleDelete(exercise)}
+ >
+
+
+ ) : null}
+
-
-
- Ansehen
-
-
- Bearbeiten
-
- handleDelete(exercise)}
- >
- Löschen
-
-
-
- ))}
+ )
+ })}
{hasMore && (
-
+
{loadingMore ? 'Laden…' : 'Mehr laden'}
diff --git a/frontend/src/pages/MediaWikiImportPage.jsx b/frontend/src/pages/MediaWikiImportPage.jsx
index 2be6dd5..8b91937 100644
--- a/frontend/src/pages/MediaWikiImportPage.jsx
+++ b/frontend/src/pages/MediaWikiImportPage.jsx
@@ -1,6 +1,14 @@
import React, { useState, useEffect } from 'react'
+import { Eye, Play, History } from 'lucide-react'
import api from '../utils/api'
import AdminPageNav from '../components/AdminPageNav'
+import PageSectionNav from '../components/PageSectionNav'
+
+const WIKI_IMPORT_TABS = [
+ { id: 'preview', label: 'Vorschau', icon: Eye },
+ { id: 'execute', label: 'Ausführen', icon: Play },
+ { id: 'history', label: 'Historie', icon: History },
+]
export default function MediaWikiImportPage() {
const [activeTab, setActiveTab] = useState('preview')
@@ -111,32 +119,12 @@ export default function MediaWikiImportPage() {
Importiere Übungen, Fähigkeiten und Methoden aus karatetrainer.net
- {/* Tabs */}
-
-
- {['preview', 'execute', 'history'].map(tab => (
- setActiveTab(tab)}
- style={{
- padding: '12px 24px',
- background: activeTab === tab ? 'var(--accent)' : 'transparent',
- color: activeTab === tab ? 'white' : 'var(--text1)',
- border: 'none',
- borderBottom: activeTab === tab ? '2px solid var(--accent)' : '2px solid transparent',
- cursor: 'pointer',
- fontSize: '16px',
- fontWeight: activeTab === tab ? 'bold' : 'normal',
- transition: 'all 0.2s'
- }}
- >
- {tab === 'preview' && '👁️ Vorschau'}
- {tab === 'execute' && '▶️ Ausführen'}
- {tab === 'history' && '📜 Historie'}
-
- ))}
-
-
+
{/* Error Display */}
{error && (
diff --git a/frontend/src/pages/SkillsPage.jsx b/frontend/src/pages/SkillsPage.jsx
index 09e66ba..0de662e 100644
--- a/frontend/src/pages/SkillsPage.jsx
+++ b/frontend/src/pages/SkillsPage.jsx
@@ -1,6 +1,12 @@
import React, { useState, useEffect } from 'react'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
+import PageSectionNav from '../components/PageSectionNav'
+
+const SKILLS_SECTION_TABS = [
+ { id: 'skills', label: 'Fähigkeiten' },
+ { id: 'methods', label: 'Trainingsmethoden' },
+]
function SkillsPage() {
const { user } = useAuth()
@@ -132,7 +138,7 @@ function SkillsPage() {
if (loading) {
return (
-
+
@@ -143,40 +149,22 @@ function SkillsPage() {
const methodsByCategory = groupByCategory(methods)
return (
-
-
Fähigkeiten & Methoden
+
+
Fähigkeiten & Methoden
- {/* Tabs */}
-
- {['skills', 'methods'].map(tab => (
- setActiveTab(tab)}
- style={{
- padding: '0.75rem 1.5rem',
- background: activeTab === tab ? 'var(--accent)' : 'transparent',
- color: activeTab === tab ? 'white' : 'var(--text1)',
- border: 'none',
- borderRadius: '8px 8px 0 0',
- cursor: 'pointer',
- fontWeight: activeTab === tab ? 'bold' : 'normal'
- }}
- >
- {tab === 'skills' ? 'Fähigkeiten' : 'Trainingsmethoden'}
-
- ))}
-
+
{/* Skills Tab */}
{activeTab === 'skills' && (
<>
-
-
+
+
Fähigkeiten sind Kompetenzen, die in Übungen trainiert werden.
{isAdmin && (
@@ -188,60 +176,46 @@ function SkillsPage() {
{Object.keys(skillsByCategory).length === 0 ? (
-
+
Keine Fähigkeiten gefunden
) : (
Object.keys(skillsByCategory).sort().map(category => (
-
-
+
+
{category}
-
+
{skillsByCategory[category].map(skill => (
-
-
-
{skill.name}
+
+
+
{skill.name}
{skill.importance && (
-
+
⭐ {skill.importance}/5
)}
{skill.description && (
-
+
{skill.description}
)}
{isAdmin && (
-
+
handleEdit(skill, 'skill')}
>
Bearbeiten
handleDelete(skill, 'skill')}
>
Löschen
@@ -260,8 +234,8 @@ function SkillsPage() {
{/* Methods Tab */}
{activeTab === 'methods' && (
<>
-
-
+
+
Trainingsmethoden sind didaktische Ansätze für die Trainingsgestaltung.
{isAdmin && (
@@ -273,52 +247,36 @@ function SkillsPage() {
{Object.keys(methodsByCategory).length === 0 ? (
-
+
Keine Trainingsmethoden gefunden
) : (
Object.keys(methodsByCategory).sort().map(category => (
-
-
+
+
{category}
-
+
{methodsByCategory[category].map(method => (
-
-
-
+
+
+
{method.name}
{method.abbreviation && (
-
+
({method.abbreviation})
)}
-
+
{method.typical_duration && (
-
+
⏱️ {method.typical_duration} min
)}
{method.typical_group_size && (
-
+
👥 {method.typical_group_size}
)}
@@ -326,27 +284,23 @@ function SkillsPage() {
{method.description && (
-
+
{method.description}
)}
{isAdmin && (
-
+
handleEdit(method, 'method')}
>
Bearbeiten
handleDelete(method, 'method')}
>
Löschen
@@ -364,36 +318,37 @@ function SkillsPage() {
{/* Modal */}
{showModal && isAdmin && (
-
-
-
- {editing
- ? (modalType === 'skill' ? 'Fähigkeit bearbeiten' : 'Methode bearbeiten')
- : (modalType === 'skill' ? 'Neue Fähigkeit' : 'Neue Methode')
- }
-
-
-