{left.title}
{left.preview}
{left.source === "ai" && AI}/* 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 ( ); } /* ===== Snow Controller (mit Keys, Verzögerung & Profil) ===== */ function useSnowController() { const [activeKeys, setActiveKeys] = useState(() => new Set()); const timersRef = useRef(new Map()); // key -> timeoutId const profilesRef = useRef(new Map()); // key -> "ai" | "modal" const [isSnowing, setIsSnowing] = useState(false); const [profile, setProfile] = useState("ai"); const recalc = useCallback((keys, profiles) => { const size = keys.size; setIsSnowing(size > 0); let hasAI = false; keys.forEach((k) => { if (profiles.get(k) === "ai") hasAI = true; }); setProfile(hasAI ? "ai" : "modal"); }, []); const start = useCallback(({ key = "default", delayMs = 800, profile = "ai" } = {}) => { const tPrev = timersRef.current.get(key); if (tPrev) clearTimeout(tPrev); const id = setTimeout(() => { setActiveKeys(prev => { if (prev.has(key)) return prev; const next = new Set(prev); next.add(key); return next; }); profilesRef.current.set(key, profile); }, Math.max(0, delayMs)); timersRef.current.set(key, id); }, []); const stop = useCallback((key) => { if (!key) { timersRef.current.forEach((id) => clearTimeout(id)); timersRef.current.clear(); profilesRef.current.clear(); setActiveKeys(new Set()); setIsSnowing(false); setProfile("ai"); return; } const t = timersRef.current.get(key); if (t) { clearTimeout(t); timersRef.current.delete(key); } profilesRef.current.delete(key); setActiveKeys(prev => { if (!prev.has(key)) return prev; const next = new Set(prev); next.delete(key); return next; }); }, []); useEffect(() => { recalc(activeKeys, profilesRef.current); }, [activeKeys, recalc]); useEffect(() => () => stop(), [stop]); // Cleanup unmount return { isSnowing, profile, start, stop }; } /* ===== Scroll-Helfer ===== */ function scrollToRef(ref, block = "start") { if (ref?.current) { ref.current.scrollIntoView({ behavior: "smooth", block }); } } function scrollToTop() { window.scrollTo({ top: 0, left: 0, behavior: "auto" }); } /* ===== Allgemeiner Dropdown-Portal-Renderer ===== */ function DropdownPortal({ anchorRef, open, maxHeight = 260, children }) { const [box, setBox] = useState(null); const update = useCallback(() => { const el = anchorRef?.current; if (!el) return; const r = el.getBoundingClientRect(); setBox({ top: Math.round(r.bottom + 8), left: Math.round(r.left), width: Math.round(r.width), }); }, [anchorRef]); useEffect(() => { if (!open) return; update(); const onScroll = () => update(); const onResize = () => update(); window.addEventListener("scroll", onScroll, true); window.addEventListener("resize", onResize); window.addEventListener("orientationchange", onResize); return () => { window.removeEventListener("scroll", onScroll, true); window.removeEventListener("resize", onResize); window.removeEventListener("orientationchange", onResize); }; }, [open, update]); if (!open || !box) return null; return ReactDOM.createPortal(
{left.preview}
{left.source === "ai" && AI}{main.preview}
{main.source === "ai" && AI}{right.preview}
{right.source === "ai" && AI}{exercise.description || exercise.preview || "–"}
–
)}{exercise.terrain}
{exercise.safety}
–
)}–
)}–
)}–
)}–
)}Diese App ist aus einer Biertisch-Idee entstanden 🍻. Im Austausch mit Fachleuten aus der Schweizer Erwachsenenbildung im Schneesport wurde klar: Eine einfache, zugängliche Sammlung von Übungen und Metaphern für alle Lern- und Lehrstufen fehlte.
Darum haben wir diese App gebaut: schnell, unkompliziert und kostenlos – Ideen für den Unterricht direkt in der Hosentasche.
Weil KI immer mehr kann, testen wir sie als Extra – Ziel: so viel über Skifahren wissen wie alle Skilehrer:innen zusammen 😉.
Aktuell: Testphase/MVP. Wir verbessern laufend, besonders die KI.
Als PWA könnt ihr sie wie eine App auf den Homescreen legen: im Browser-Menü (iPhone meist unten) „Zum Startbildschirm hinzufügen“. Keine Registrierung, keine Kreditkarte – einfach, schnell, stabil, kostenlos.
> ); } function WichtigesContent() { return ( <>POLTIS GmbH
Murtenstrasse 143 D
3008 Bern
Schweiz
Wir verkaufen nichts und sammeln keine unnötigen Daten. Wir nutzen Google Analytics 4 (GA4), um zu verstehen, welche Inhalte genutzt werden. Die Daten werden anonymisiert (z. B. IP-Anonymisierung); Rückschlüsse auf Einzelpersonen sind nicht möglich. Keine Weitergabe an Dritte zu Werbezwecken.
Wer das nicht möchte, kann Cookies im Browser einschränken/blockieren.
> ); } /* ===== Main App ===== */ function App() { const [searchOpen, setSearchOpen] = useState(false); const [outdoor, setOutdoor] = useState(false); const [level, setLevel] = useState(null); const [lernstufe, setLernstufe] = useState(null); const [movementsSel, setMovementsSel] = useState([]); // Suchmodus const [movementFilter, setMovementFilter] = useState(null); // Dropdown-Filter const [exercises, setExercises] = useState([]); const [isLoading, setIsLoading] = useState(false); const [loadError, setLoadError] = useState(null); const [selected, setSelected] = useState(null); const [offset, setOffset] = useState(0); const PAGE_SIZE = 5; const [focusIndex, setFocusIndex] = useState(0); const lernstufeRef = useRef(null); const movementFilterRef = useRef(null); const carouselRef = useRef(null); const [showInfos, setShowInfos] = useState(false); const [showWichtiges, setShowWichtiges] = useState(false); const [showSearchHint, setShowSearchHint] = useState(true); const searchBtnRef = useRef(null); const outdoorBtnRef = useRef(null); const [hintPos, setHintPos] = useState({ top: 0, left: 0 }); const [outdoorTop, setOutdoorTop] = useState(null); const [movementOpts, setMovementOpts] = useState([]); // string[] const [canon, setCanon] = useState({ all: [], byLevel: {}, orderMap: new Map() }); // Schnee-Controller (stabile Funktionen extrahieren) const snowCtl = useSnowController(); const snowStart = snowCtl.start; const snowStop = snowCtl.stop; // Query-Dedupe + Stale-Run-Guard const lastQueryKeyRef = useRef(""); const runIdRef = useRef(0); const loadMoreRunningRef = useRef(false); // Taxonomie einmalig laden useEffect(() => { let cancelled = false; (async () => { try { const r = await fetchWithTimeout(`${API_BASE}/api/taxonomy`); const t = await r.json(); const order = Array.isArray(t.canonOrder) && t.canonOrder.length ? t.canonOrder : [...new Set(Object.values(t.movementsByLevel || {}).flat())]; const all = Array.isArray(t.movementsAll) && t.movementsAll.length ? t.movementsAll : order; const orderMap = new Map(order.map((name, i) => [UI_LABEL.get(name) || name, i])); if (!cancelled) setCanon({ all: (all || []).map(n => UI_LABEL.get(n) || n), byLevel: t.movementsByLevel || {}, orderMap }); } catch (e) { console.error('taxonomy load failed', e); } })(); return () => { cancelled = true; }; }, []); const positionHint = useCallback(() => { const btn = searchBtnRef.current; if (!btn) return; const b = btn.getBoundingClientRect(); const gap = window.innerWidth <= 480 ? 6 : 10; const top = Math.round(b.top + b.height / 2); const left = Math.round(b.right + gap); setHintPos({ top, left }); }, []); const positionOutdoor = useCallback(() => { const searchBtn = searchBtnRef.current; const outdoorBtn = outdoorBtnRef.current; if (!searchBtn || !outdoorBtn) return; const sb = searchBtn.getBoundingClientRect(); const ob = outdoorBtn.getBoundingClientRect(); const mid = Math.round(sb.top + sb.height / 2); const top = Math.round(mid - ob.height / 2); setOutdoorTop(top); }, []); useEffect(() => { positionHint(); positionOutdoor(); const onResize = () => { positionHint(); positionOutdoor(); }; window.addEventListener("resize", onResize); window.addEventListener("orientationchange", onResize); const ro = new ResizeObserver(() => { positionHint(); positionOutdoor(); }); if (searchBtnRef.current) ro.observe(searchBtnRef.current); if (outdoorBtnRef.current) ro.observe(outdoorBtnRef.current); return () => { window.removeEventListener("resize", onResize); window.removeEventListener("orientationchange", onResize); ro.disconnect(); }; }, [positionHint, positionOutdoor]); useEffect(() => { document.body.classList.toggle("outdoor-mode", outdoor); }, [outdoor]); // Umschalten Suche <-> Level useEffect(() => { if (searchOpen) { setLevel(null); setMovementFilter(null); setMovementOpts([]); } else { setMovementsSel([]); setLernstufe(null); setMovementFilter(null); setMovementOpts([]); } setExercises([]); setOffset(0); setFocusIndex(0); setTimeout(() => { scrollToTop(); positionHint(); positionOutdoor(); }, 0); }, [searchOpen, positionHint, positionOutdoor]); // ===== Scroll-Reihenfolgen ===== useEffect(() => { if (!searchOpen && level) setTimeout(() => scrollToRef(lernstufeRef, "center"), 60); }, [searchOpen, level]); useEffect(() => { if (!searchOpen && level && lernstufe) setTimeout(() => scrollToRef(movementFilterRef, "center"), 60); }, [searchOpen, level, lernstufe]); useEffect(() => { if (!searchOpen && level && lernstufe && typeof movementFilter === "string" && movementFilter.trim() !== "") { setTimeout(() => scrollToRef(carouselRef, "start"), 60); } }, [searchOpen, level, lernstufe, movementFilter]); useEffect(() => { if (searchOpen && movementsSel.length > 0) setTimeout(() => scrollToRef(lernstufeRef, "center"), 60); }, [searchOpen, movementsSel.length]); useEffect(() => { if (searchOpen && movementsSel.length > 0 && lernstufe) { setTimeout(() => scrollToRef(carouselRef, "start"), 60); } }, [searchOpen, movementsSel.length, lernstufe]); // ❄️ Popups steuern Schnee (sofort an/aus, Modal-Profil) useEffect(() => { const key = "modal-infos"; if (showInfos) snowStart({ key, delayMs: 0, profile: "modal" }); else snowStop(key); }, [showInfos, snowStart, snowStop]); useEffect(() => { const key = "modal-wichtiges"; if (showWichtiges) snowStart({ key, delayMs: 0, profile: "modal" }); else snowStop(key); }, [showWichtiges, snowStart, snowStop]); // ===== Erstladen (5er) bei Filteränderung ===== useEffect(() => { const run = async () => { setLoadError(null); // Guards: Anforderungen prüfen if (searchOpen) { if (!lernstufe || movementsSel.length === 0) return; } else { if (!lernstufe || !level) return; } // Query bauen let query; if (searchOpen) { query = { query_type: "variant_2", filters: { movements: movementsSel, lernstufe }, offset: 0, size: PAGE_SIZE }; } else { const baseFilters = { level, lernstufe }; if (movementFilter && movementFilter.trim() !== "") baseFilters.bewegungsform = movementFilter; query = { query_type: "variant_1", filters: baseFilters, offset: 0, size: PAGE_SIZE }; } // Dedupe: gleiche Query nicht erneut senden const key = JSON.stringify(query); if (lastQueryKeyRef.current === key) return; lastQueryKeyRef.current = key; // State reset setExercises([]); setOffset(0); setFocusIndex(0); setIsLoading(true); // ❄️ Var2 schnellerer Trigger, Var1 später const snowKey = "initial-load"; snowStart({ key: snowKey, delayMs: searchOpen ? 450 : 850, profile: "ai" }); // Stale-Run-Guard const myRun = ++runIdRef.current; try { const { items, nextOffset, movementsOptions, movementsAll } = await apiQueryMixed(query); if (myRun !== runIdRef.current) return; // überholt setExercises(items); setOffset(nextOffset); setFocusIndex(0); if (!searchOpen) { let names = movementsOptions.length ? movementsOptions : movementsAll; if ((!names || !names.length) && level && lernstufe) { try { names = await fetchMovementOptions({ lernstufe, level }); } catch {} } const orderMap = canon.orderMap; const canonicalSorted = [...new Set(names)] .filter(n => !BANNED_MOVES.has(n)) .sort((a,b) => { const ia = orderMap?.get?.(a) ?? 9999; const ib = orderMap?.get?.(b) ?? 9999; return ia - ib || a.localeCompare(b, "de"); }); setMovementOpts(canonicalSorted); } else { setMovementOpts([]); } if (searchOpen) { if (movementsSel.length > 0 && lernstufe) { setTimeout(() => scrollToRef(carouselRef, "start"), 20); } } else { if (movementFilter && movementFilter.trim() !== "") { setTimeout(() => scrollToRef(carouselRef, "start"), 20); } } } catch (e) { if (myRun !== runIdRef.current) return; setLoadError(e.message || "Fehler beim Laden"); } finally { if (myRun === runIdRef.current) { setIsLoading(false); snowStop(snowKey); } } }; run(); // WICHTIG: nur stabile Abhängigkeiten + die Filterstates }, [searchOpen, movementsSel, level, lernstufe, movementFilter, canon.orderMap, snowStart, snowStop]); // „Load more“ baut auf gleichem Query auf const currentMixedQuery = () => { if (!lernstufe) return null; if (searchOpen) { if (movementsSel.length === 0) return null; return { query_type: "variant_2", filters: { movements: movementsSel, lernstufe } }; } if (!level) return null; const baseFilters = { level, lernstufe }; if (movementFilter && movementFilter.trim() !== "") baseFilters.bewegungsform = movementFilter; return { query_type: "variant_1", filters: baseFilters }; }; const canShowLoadMore = (!searchOpen && !!lernstufe && !!level) || (searchOpen && !!lernstufe && movementsSel.length > 0); async function onLoadMore() { const base = currentMixedQuery(); if (!base || loadMoreRunningRef.current) return; loadMoreRunningRef.current = true; const oldLen = exercises.length; setIsLoading(true); const snowKey = "load-more"; snowStart({ key: snowKey, delayMs: searchOpen ? 450 : 850, profile: "ai" }); try { const { items, nextOffset } = await apiQueryMixed({ ...base, offset, size: PAGE_SIZE }); if (items.length > 0) { setExercises((prev) => [...prev, ...items]); setOffset(nextOffset); setFocusIndex(oldLen); setTimeout(() => scrollToRef(carouselRef, "start"), 10); } } catch (e) { alert("Laden fehlgeschlagen: " + (e.message || e)); } finally { setIsLoading(false); snowStop(snowKey); loadMoreRunningRef.current = false; } } // ❄️ Metaphern – mit Schnee (kurzer Delay) async function onAddMetaphors(ex) { const snowKey = `meta-${ex?.id || "x"}`; snowStart({ key: snowKey, delayMs: 400, profile: "ai" }); try { const added = await aiAddMetaphors(ex.id); setSelected((s) => (s ? { ...s, metaphors: [...(s.metaphors || []), ...added] } : s)); setExercises((list) => list.map((it) => (it.id === ex.id ? { ...it, metaphors: [...(it.metaphors || []), ...added] } : it))); return added; } catch (e) { alert("Metaphern-Generierung fehlgeschlagen: " + (e.message || e)); return []; } finally { snowStop(snowKey); } } // Anzeige-Liste const shownExercises = useMemo( () => exercises.filter(ex => !(ex.movements || []).some(m => BANNED_MOVES.has(m))), [exercises] ); // Hash-Handling für Popups useEffect(() => { const openFromHash = () => { const h = (window.location.hash || "").toLowerCase(); setShowInfos(h.includes("infos")); setShowWichtiges(h.includes("wichtiges")); }; openFromHash(); window.addEventListener("hashchange", openFromHash); return () => window.removeEventListener("hashchange", openFromHash); }, []); const closeInfos = () => { setShowInfos(false); if (location.hash) history.replaceState(null, "", location.pathname + location.search); }; const closeWichtiges = () => { setShowWichtiges(false); if (location.hash) history.replaceState(null, "", location.pathname + location.search); }; // ===== Styles (inkl. Portal-Tuning & sichere Breiten) ===== const neonCSS = ` @keyframes tg-neon-blink { 0%, 100% { opacity: 1 } 50% { opacity: .8 } } .tg-searchhint { position: fixed; z-index: 9999; pointer-events: none; user-select: none; display: inline-flex; align-items: center; gap: 10px; padding: 0; background: transparent; border: 0; border-radius: 0; font-weight: 900; letter-spacing: .2px; font-size: clamp(14px, 1.8vw, 18px); line-height: 1.15; white-space: nowrap; } .tg-searchhint .arrow, .tg-searchhint .text { color: #0b0b0b; -webkit-text-stroke: .5px rgba(0,0,0,.9); text-shadow: 0 0 6px rgba(235,251,59,.95), 0 0 12px rgba(235,251,59,.8), 0 0 22px rgba(235,251,59,.65); filter: drop-shadow(0 0 6px rgba(235,251,59,.9)) drop-shadow(0 0 14px rgba(235,251,59,.6)); animation: tg-neon-blink 1.05s infinite; } .tg-searchhint .arrow { font-size: 1.15em; transform: translateY(-.5px); } @media (max-width: 480px) { .tg-searchhint { font-size: 13px; gap: 8px; } } /* ===== Level/Lernstufe-Seite: 2 Boxen oben, Bewegungsform-Filter darunter ===== */ .filters--level { display: grid; gap: 16px; } .filters--level .row2 { display: grid; grid-template-columns: 1fr; gap: 16px; } @media (min-width: 900px) { .filters--level .row2 { grid-template-columns: repeat(2, minmax(260px, 1fr)); } } /* ===== Suchseite: breite, aber sichere Suchbox ===== */ .filters--search { display: flex; justify-content: center; } /* Basisbreite */ .filters--search .searchBoxXL { width: clamp(900px, 65vw, 1320px); margin: 0 auto; box-sizing: border-box; } .filters--search .searchBoxXL .searchInput, .filters--search .searchBoxXL .selectedWrap { width: 100%; max-width: 100%; box-sizing: border-box; } /* ▼ Desktop: ~30% schmaler (65vw → 45vw) */ @media (min-width: 900px) { .filters--search .searchBoxXL { width: clamp(700px, 45vw, 1050px); } .filters--level .searchWrap .searchInput { font-size: 16px; } .filters--level .tg-portal .suggestion, .filters--level .suggestions .suggestion { font-size: 16px; } } /* ▼ Mobile: vollflächig mit Rand */ @media (max-width: 768px) { .filters--search .searchBoxXL { width: calc(100vw - 32px); margin-left: 16px; margin-right: 16px; } } /* ▼ Level-Ansicht: kein Clipping mehr */ .filters--level .searchWrap { overflow: visible; position: relative; } /* Portal-Feintuning */ .tg-portal { } .filters.single { grid-template-columns: 1fr !important; } `; return (