diff --git a/backend/routers/catalogs.py b/backend/routers/catalogs.py index 5f4f2c9..672554f 100644 --- a/backend/routers/catalogs.py +++ b/backend/routers/catalogs.py @@ -14,6 +14,17 @@ from auth import require_auth router = APIRouter(prefix="/api", tags=["catalogs"]) +def _sql_active_status(column: str, status: Optional[str]) -> tuple[str, list]: + """ + Filter „active“ schließt Legacy-Zeilen mit status IS NULL ein (sonst leere Dropdowns in der UI). + """ + if not status: + return "", [] + if status == "active": + return f" ({column} = 'active' OR {column} IS NULL)", [] + return f" ({column} = %s)", [status] + + def _slugify_skill_label(text: str) -> str: t = (text or "").strip().lower() t = re.sub(r"[^a-z0-9äöüß]+", "_", t, flags=re.IGNORECASE) @@ -155,9 +166,10 @@ def list_focus_areas( query = "SELECT * FROM focus_areas" params = [] - if status: - query += " WHERE status = %s" - params.append(status) + frag, extra = _sql_active_status("status", status) + if frag: + query += " WHERE" + frag + params.extend(extra) query += " ORDER BY sort_order, name" @@ -275,9 +287,10 @@ def list_training_styles( """ params = [] - if status: - query += " WHERE ts.status = %s" - params.append(status) + frag, extra = _sql_active_status("ts.status", status) + if frag: + query += " WHERE" + frag + params.extend(extra) query += " ORDER BY ts.sort_order, ts.name" @@ -517,9 +530,10 @@ def list_training_types( params = [] where = [] - if status: - where.append("tt.status = %s") - params.append(status) + frag, extra = _sql_active_status("tt.status", status) + if frag: + where.append(frag.strip()) + params.extend(extra) if focus_area_id is not None: where.append("tt.focus_area_id = %s") @@ -955,9 +969,10 @@ def list_target_groups( params = [] where = [] - if status: - where.append("status = %s") - params.append(status) + frag, extra = _sql_active_status("status", status) + if frag: + where.append(frag.strip()) + params.extend(extra) if where: query += " WHERE " + " AND ".join(where) diff --git a/backend/routers/skills.py b/backend/routers/skills.py index 64582dd..a60c062 100644 --- a/backend/routers/skills.py +++ b/backend/routers/skills.py @@ -53,11 +53,14 @@ def list_skills( params.append(category) if status: - where.append("status = %s") - params.append(status) + if status == "active": + where.append("(status = 'active' OR status IS NULL)") + else: + where.append("status = %s") + params.append(status) else: - # Default: only active skills - where.append("status = 'active'") + # Default: nur aktive (NULL = Legacy, wie Kataloglisten) + where.append("(status = 'active' OR status IS NULL)") if where: query += " WHERE " + " AND ".join(where) diff --git a/frontend/src/app.css b/frontend/src/app.css index d4b2d59..8b1ee08 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -2258,8 +2258,10 @@ a.analysis-split__nav-item { outline: none; font-size: 15px; line-height: 1.5; - max-height: 50vh; + min-height: 120px; + max-height: min(70vh, 520px); overflow-y: auto; + resize: vertical; } .rich-text-editor:empty:before { content: attr(data-placeholder); diff --git a/frontend/src/components/RichTextEditor.jsx b/frontend/src/components/RichTextEditor.jsx index 5340cd1..f5254ea 100644 --- a/frontend/src/components/RichTextEditor.jsx +++ b/frontend/src/components/RichTextEditor.jsx @@ -2,76 +2,103 @@ import React, { useRef, useEffect, useState, useCallback } from 'react' function exec(cmd, value = null) { try { - document.execCommand(cmd, false, value) + return document.execCommand(cmd, false, value) } catch (_) { - /* ignore */ + return false } } +/** Browser: formatBlock erwartet oft Tag in Großschreibung. */ +function formatBlock(tag) { + const t = String(tag).toUpperCase() + if (!exec('formatBlock', t)) { + exec('formatBlock', tag.toLowerCase()) + } +} + +function normalText() { + exec('removeFormat') + formatBlock('p') +} + /** - * Leichter WYSIWYG-Editor (contenteditable) — ohne zusätzliche npm-Pakete. - * Speichert HTML; Anzeige über sanitizeTrainerHtml + dangerouslySetInnerHTML. + * Leichter WYSIWYG (contenteditable). Wert kommt von außen zuverlässig ins DOM (Edit-Modus). */ export default function RichTextEditor({ value, onChange, placeholder, minHeight = '140px' }) { const ref = useRef(null) const [focused, setFocused] = useState(false) - const lastExternal = useRef(value) useEffect(() => { - if (!ref.current) return - if (focused) return - if (value !== lastExternal.current) { - lastExternal.current = value - if (ref.current.innerHTML !== (value || '')) { - ref.current.innerHTML = value || '' - } + const el = ref.current + if (!el || focused) return + const next = value ?? '' + if (el.innerHTML !== next) { + el.innerHTML = next } }, [value, focused]) const sync = useCallback(() => { if (!ref.current) return - const html = ref.current.innerHTML - lastExternal.current = html - onChange(html) + onChange(ref.current.innerHTML) }, [onChange]) - const onLink = () => { - const url = window.prompt('Link-URL (https://…)') - if (url) exec('createLink', url) + const run = (fn) => (e) => { + e.preventDefault() + ref.current?.focus() + fn() sync() } + const onLink = (e) => { + e.preventDefault() + ref.current?.focus() + const url = window.prompt('Link-URL (https://…)') + if (url) { + exec('createLink', url) + sync() + } + } + return (
- - - - - - - - + + + -
{ tags.push({ key: `tg-${g.id}`, label: g.name, accent: !!g.is_primary }) }) - ;(exercise.age_groups || []).forEach((ag) => { - tags.push({ key: `ag-${ag}`, label: ag, accent: false }) - }) if (tags.length === 0) return null return (
diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx index 9f720f8..f2e66ea 100644 --- a/frontend/src/pages/ExerciseFormPage.jsx +++ b/frontend/src/pages/ExerciseFormPage.jsx @@ -19,8 +19,6 @@ const LEVEL_OPTIONS = [ { value: 'experte', label: 'Experte' }, ] -const AGE_GROUP_OPTIONS = ['Minis', 'Kinder', 'Schüler', 'Teenager', 'Erwachsene'] - function emptyForm() { return { title: '', @@ -34,7 +32,6 @@ function emptyForm() { duration_max: '', group_size_min: '', group_size_max: '', - age_groups: [], focus_areas_multi: [], training_styles_multi: [], training_types_multi: [], @@ -58,7 +55,6 @@ function detailToForm(exercise) { duration_max: exercise.duration_max ?? '', group_size_min: exercise.group_size_min ?? '', group_size_max: exercise.group_size_max ?? '', - age_groups: exercise.age_groups || [], focus_areas_multi: (exercise.focus_areas || []).map((f) => ({ focus_area_id: f.focus_area_id, is_primary: !!f.is_primary, @@ -195,7 +191,13 @@ function ExerciseFormPage() { setTrainingTypes(ttData) setTargetGroups(tgData) } catch (e) { - if (!cancelled) console.error(e) + if (!cancelled) { + console.error(e) + alert( + 'Kataloge (Fokus, Stile, Zielgruppen, Fähigkeiten) konnten nicht geladen werden: ' + + (e.message || e), + ) + } } } boot() @@ -238,13 +240,6 @@ function ExerciseFormPage() { setFormData((prev) => ({ ...prev, [field]: value })) } - const toggleAgeGroup = (name) => { - const set = new Set(formData.age_groups) - if (set.has(name)) set.delete(name) - else set.add(name) - updateFormField('age_groups', [...set]) - } - const addSkillRow = () => { const id = skillPick ? parseInt(skillPick, 10) : null if (!id) { @@ -545,22 +540,6 @@ function ExerciseFormPage() {
-
- -
- {AGE_GROUP_OPTIONS.map((ag) => ( - - ))} -
-
- - Varianten-Editor folgt in einem späteren Schritt (API ist teilweise vorhanden). + Varianten-Editor folgt später (API teilweise vorhanden).{' '} + KI-Ausbaustufe: Backend laut Spec{' '} + POST /api/exercises/ai/suggest und{' '} + POST /api/exercises/{'{id}'}/ai/regenerate — z. B.{' '} + OPENROUTER_API_KEY, Vorschläge nur nach Trainer-Bestätigung übernehmen (siehe{' '} + api.suggestExerciseAi).

) diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 65455d8..54bfcb5 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -263,7 +263,7 @@ export function buildExerciseApiPayload(formData) { training_styles_multi: mapStyles, training_types_multi: mapTTypes, target_groups_multi: mapTg, - age_groups: formData.age_groups || [], + age_groups: [], skills: (formData.skills || []).map((s) => ({ skill_id: s.skill_id, is_primary: !!s.is_primary, @@ -340,6 +340,21 @@ export async function deleteExercise(id) { return request(`/api/exercises/${id}`, { method: 'DELETE' }) } +/** KI-Ausbaustufe (EXERCISES_API_SPEC): benötigt Backend + z. B. OPENROUTER_API_KEY. */ +export async function suggestExerciseAi(payload) { + return request('/api/exercises/ai/suggest', { + method: 'POST', + body: JSON.stringify(payload), + }) +} + +export async function regenerateExerciseAi(exerciseId, payload) { + return request(`/api/exercises/${exerciseId}/ai/regenerate`, { + method: 'POST', + body: JSON.stringify(payload), + }) +} + // ============================================================================ // Catalogs (Admin-verwaltbare Stammdaten) // ============================================================================ @@ -785,6 +800,8 @@ export const api = { updateExercise, deleteExercise, buildExerciseApiPayload, + suggestExerciseAi, + regenerateExerciseAi, uploadExerciseMedia, updateExerciseMedia, deleteExerciseMedia,