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 (
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).