/* global React, ReactDOM */ const { useEffect, useRef, useState, useCallback, useMemo } = React; /* — Anzeige≡API, optionale Label-Mappings & Bans — */ const UI_LABEL = new Map(); const API_NAME = new Map(); const BANNED_MOVES = new Set(); // Backend-Basis: const API_BASE = (location.hostname === "localhost" || location.hostname === "127.0.0.1") ? "http://localhost:3001" : ""; /* ===== Helper: fetch mit Timeout ===== */ const API_TIMEOUT_MS = 25000; async function fetchWithTimeout(url, options = {}, timeoutMs = API_TIMEOUT_MS) { const controller = new AbortController(); const id = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(url, { ...options, signal: controller.signal }); return res; } finally { clearTimeout(id); } } /* ===== Helpers/API ===== */ function mapExercises(list) { // 🔹 Formatierung für spezielle Schlüsselwörter in description const formatSpecial = (text) => { if (!text) return ""; return String(text) .replace(/(Aufgabe:)/g, '\n$1') .replace(/(Technik:)/g, '\n$1') .replace(/(Kurzprofil:)/g, '\n$1') .replace(/(Variante:)/g, '\n$1') .replace(/(Didaktik:)/g, '\n$1') .trim(); }; const splitBullets = (text) => String(text || "") .split(/\r?\n|[•●▪·]+/g) .map((s) => s.trim()) .filter(Boolean); const mapped = (list || []).map((e, i) => { const rawMoves = Array.isArray(e.movements) ? e.movements.filter(Boolean) : (e.movement ? [e.movement] : []); const uiMoves = rawMoves .map(m => (UI_LABEL.get(m) || m)) .filter(m => !BANNED_MOVES.has(m)); return { id: e.id ?? `ex-${i + 1}`, title: e.title || "Übung", // 🔹 hier description formatieren description: formatSpecial(e.description || ""), preview: e.description ? String(e.description).slice(0, 140) : "", level: e.level || "", lernstufe: e.lernstufe || e.lehrstufe || "", movements: uiMoves, exercises: Array.isArray(e.exercises) ? e.exercises.flatMap((s) => splitBullets(s)) : splitBullets(e.exercises), terrain: e.terrain || "", safety: e.safety || "", metaphors: Array.isArray(e.metaphors) ? e.metaphors : [], tools: Array.isArray(e.tools) ? e.tools : [], games: Array.isArray(e.games) ? e.games : [], organization: Array.isArray(e.organization) ? e.organization : [], tips: Array.isArray(e.tips) ? e.tips : [], source: e.source || "manual", }; }); const seen = new Set(); return mapped.filter((e) => { if ((e.movements || []).some(m => BANNED_MOVES.has(m))) return false; const k = (e.title || "") + "|" + (e.description || ""); if (seen.has(k)) return false; seen.add(k); return true; }); } // Mixed-Query: inkl. movementsOptions/movementsAll async function apiQueryMixed(queryObj) { const clone = JSON.parse(JSON.stringify(queryObj || {})); const res = await fetchWithTimeout(`${API_BASE}/api/query/mixed`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(clone), }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(text || `API ${res.status}`); } const data = await res.json(); const toUi = (arr) => (arr || []) .map(x => typeof x === "string" ? x : x?.name) .filter(Boolean) .map(n => UI_LABEL.get(n) || n) .filter(n => !BANNED_MOVES.has(n)); return { items: mapExercises(data?.items || []), nextOffset: data?.nextOffset ?? 0, movementsOptions: toUi(data?.movementsOptions?.map(o => o.name) || []), movementsAll: toUi(data?.movementsAll || []) }; } // Nur Options (immer ohne Counts anzeigen) async function fetchMovementOptions({ lernstufe, level }) { const url = `${API_BASE}/api/movements/options?lernstufe=${encodeURIComponent(lernstufe)}&level=${encodeURIComponent(level)}&includeAll=0`; const r = await fetchWithTimeout(url); if (!r.ok) throw new Error(`options failed: ${r.status}`); const { items } = await r.json(); // items: [{name, count}] return (items || []) .map(o => o?.name) .filter(Boolean) .map(n => UI_LABEL.get(n) || n) .filter(n => !BANNED_MOVES.has(n)); } // Robustes Error-Handling für Metaphern-Top-up async function aiAddMetaphors(id) { const res = await fetchWithTimeout(`${API_BASE}/api/ai/metaphors`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id }), }); if (!res.ok) { let msg = `AI ${res.status}`; try { const text = await res.text(); try { const j = JSON.parse(text || "{}"); msg = j?.details?.message || j?.message || msg; } catch { msg = text || msg; } } catch {} throw new Error(msg); } const data = await res.json(); const list = Array.isArray(data?.added) ? data.added : []; return list.map((m) => ({ text: m.text, age: m.age || "" })); } /* ===== Schneeflocken Overlay (leichtgewichtig, mobil-optimiert) ===== */ function SnowOverlay({ active, profile = "ai" /* "ai" | "modal" */ }) { const canvasRef = useRef(null); const rafRef = useRef(0); useEffect(() => { if (!active) return; const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); let w, h, pr, particles = []; let last = 0; const prefersReduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; const fps = profile === "modal" ? 24 : 30; const frameBudget = 1000 / fps; const densityMul = profile === "modal" ? 0.6 : 1.0; function resize() { w = window.innerWidth; h = window.innerHeight; canvas.style.width = w + "px"; canvas.style.height = h + "px"; pr = Math.min(window.devicePixelRatio || 1, 1.5); // Cap canvas.width = Math.floor(w * pr); canvas.height = Math.floor(h * pr); let base = prefersReduced ? 20 : Math.round(100 * densityMul); if (w <= 768) base = Math.round(base * 0.55); if (w <= 480) base = Math.round(base * 0.4); particles = Array.from({ length: base }, () => ({ x: Math.random() * w, y: Math.random() * h, r: (Math.random() * 1.8 + 0.8) * pr, vy: (Math.random() * 0.6 + 0.4), sway: Math.random() * 0.012 + 0.003, phase: Math.random() * Math.PI * 2 })); last = 0; } function draw(ts) { if (!last) last = ts; const dt = ts - last; if (dt >= frameBudget) { last = ts; const W = canvas.width, H = canvas.height; ctx.clearRect(0, 0, W, H); ctx.fillStyle = "rgba(255,255,255,0.95)"; ctx.beginPath(); for (let p of particles) { p.phase += p.sway; p.y += 1 + p.vy; p.x += Math.sin(p.phase) * 0.4; if (p.y - p.r / pr > h) { p.y = -2; p.x = Math.random() * w; p.phase = Math.random() * Math.PI * 2; } else if (p.x < -10) { p.x = w + 10; } else if (p.x > w + 10) { p.x = -10; } const px = p.x * pr, py = p.y * pr; ctx.moveTo(px, py); ctx.arc(px, py, p.r, 0, Math.PI * 2); } ctx.fill(); } rafRef.current = requestAnimationFrame(draw); } const onResize = () => resize(); const onVis = () => { if (document.hidden) cancelAnimationFrame(rafRef.current); else { last = 0; rafRef.current = requestAnimationFrame(draw); } }; resize(); rafRef.current = requestAnimationFrame(draw); window.addEventListener("resize", onResize); document.addEventListener("visibilitychange", onVis); return () => { cancelAnimationFrame(rafRef.current); window.removeEventListener("resize", onResize); document.removeEventListener("visibilitychange", onVis); }; }, [active, profile]); if (!active) return null; return (