commit c5c6a6cd3263e98c8627ca4305b3d123dccd5291 Author: Qognio Bot Extract Date: Wed Apr 29 01:35:48 2026 +0200 init: extract physio-tutor from qognio-bot-widget-template@d2c816f Source files (src/) and rendered bundle (www/) extracted on 2026-04-29T01:35:48+02:00. Adds nginx:alpine Dockerfile + docker-compose.yml (Caddy-labels) so the bot runs stand-alone or as a per-customer template clone. Parent monorepo commit: d2c816f3edbc9760802a11b29ff4151c7aad4b46 Bot version: 2026-04-21 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9e02047 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.gitignore +README.md +bot.json +src/ +docker-compose.yml +*.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6758e09 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +*.log +*.tmp +node_modules/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1f9c43c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +# Static-bundle bot — nginx:alpine serves www/ on port 80. +FROM nginx:1.27-alpine + +# nginx config: gzip + cache headers + index.html no-store +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Static bundle +COPY www/ /usr/share/nginx/html/ + +# Run as non-root via nginx's built-in unprivileged image features +EXPOSE 80 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q --spider http://127.0.0.1/index.html || exit 1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..0493dcb --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# Luna — PhysioTutor + +Luna — dein KI-PhysioTutor. Gamified lernen mit Chat, Quiz, Flashcards und Fortschritts-Tracking. Läuft im deutschen Rechenzentrum. + +``` +slug : physio-tutor +version : 2026-04-21 +accent : #a855f7 +runtime : nginx:alpine (static bundle) +template : qognio-bot-template-core (former qognio-bot-widget-template) +``` + +## Layout + +``` +. +├── src/ source — config.yaml, welcome.html, curricula.json, etc. +├── www/ rendered, directly servable static bundle +├── Dockerfile nginx:alpine + www/ → port 80 +├── docker-compose.yml bot-host pattern (Caddy-labels, restart unless-stopped) +├── nginx.conf gzip + cache + SPA fallback +└── bot.json metadata + parent_core_commit +``` + +## Run locally + +```bash +docker compose up --build +# → http://localhost (you'll need to tweak ports for local-only use) +``` + +## Re-render after upstream core changes + +This repo only stores src + rendered output; the rendering engine lives in +`qognio-bot-template-core`. To pull in core changes: + +```bash +cd /path/to/qognio-bot-template-core +./scripts/render.sh physio-tutor --bot-repo /path/to/this/repo +git -C /path/to/this/repo commit -am "render: refresh from core@" +``` + +## Per-customer copy (template usage) + +This repo is a **template**. To clone for a customer: + +```bash +git clone my-customer-physio-tutor +cd my-customer-physio-tutor +# tweak src/config.yaml (slug, bot_key_value, accent), src/welcome.html, src/curricula.json +docker compose -f docker-compose.yml up --build +``` + +## Deploy to qognio bot-host (.42 LXC pattern — legacy) + +The bot-manager spawns LXC containers named after the slug. Push www/ via: + +```bash +ssh fmh@46.243.203.42 +sudo lxc file push /tmp/www/* physio-tutor/var/www/html/ +``` + +(Or run the docker-compose pattern on a Docker host — same network as Caddy.) + +--- + +Generated by `qognio-bot-template-core/scripts/extract-to-repo.sh` on 2026-04-29T01:35:48+02:00. diff --git a/bot.json b/bot.json new file mode 100644 index 0000000..af213ed --- /dev/null +++ b/bot.json @@ -0,0 +1,14 @@ +{ + "slug": "physio-tutor", + "name": "Luna", + "title": "PhysioTutor", + "tagline": "PhysioTutor", + "description": "Luna — dein KI-PhysioTutor. Gamified lernen mit Chat, Quiz, Flashcards und Fortschritts-Tracking. Läuft im deutschen Rechenzentrum.", + "version": "2026-04-21", + "accent": "#a855f7", + "extracted_from": "qognio-bot-widget-template", + "parent_core_commit": "d2c816f3edbc9760802a11b29ff4151c7aad4b46", + "extracted_at": "2026-04-29T01:35:48+02:00", + "runtime": "nginx:alpine", + "default_port": 80 +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e5e1f89 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +# Stand-alone bot container. +# Designed for the "caddy" external network on the bot host (qognio pattern). +# Override the hostname via SLUG env var if you reuse this template per customer. +services: + bot: + build: . + image: qognio/bot-physio-tutor:${TAG:-latest} + container_name: bot-physio-tutor + restart: unless-stopped + networks: + - caddy + labels: + caddy: "physio-tutor.on.qognio.com" + caddy.reverse_proxy: "{{upstreams 80}}" + qognio.bot.slug: "physio-tutor" + qognio.bot.version: "2026-04-21" + +networks: + caddy: + external: true diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..30cda98 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,27 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # gzip + gzip on; + gzip_vary on; + gzip_types text/css application/javascript application/json image/svg+xml text/plain; + gzip_min_length 512; + + # index.html: never cache (so welcome screen / wiring updates land instantly) + location = /index.html { + add_header Cache-Control "no-store, must-revalidate" always; + } + + # static assets: cache 1h + location ~* \.(?:css|js|json|svg|png|jpe?g|webp|gif|ico|woff2?)$ { + add_header Cache-Control "public, max-age=3600" always; + try_files $uri =404; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/src/check-badges.js b/src/check-badges.js new file mode 100644 index 0000000..a658428 --- /dev/null +++ b/src/check-badges.js @@ -0,0 +1,8 @@ + if (state.completedQuizzes >= 1) unlockBadge('first_quiz'); + if (state.maxQuizStreak >= 10) unlockBadge('10_quiz_streak'); + if (state.totalAnswers >= 100) unlockBadge('100_answers'); + if (state.maxStreak >= 7) unlockBadge('7_day_streak'); + if (state.completedCurricula.length >= 1) unlockBadge('curriculum_complete'); + const h = new Date().getHours(); + if (h >= 22) unlockBadge('night_owl'); + if (h < 7) unlockBadge('early_bird'); \ No newline at end of file diff --git a/src/cockpit-overlay/cockpit.css b/src/cockpit-overlay/cockpit.css new file mode 100644 index 0000000..fce4a9d --- /dev/null +++ b/src/cockpit-overlay/cockpit.css @@ -0,0 +1,650 @@ +/* Luna Cockpit v3 — purple #a855f7, dark, no external deps */ + +:root { + --bg: #0a0a0f; + --bg-2: #11111b; + --bg-3: #1a1a26; + --bg-4: #232336; + --line: #2a2a3e; + --text: #e8e8f0; + --text-muted: #9090a8; + --text-dim: #6a6a85; + --accent: #a855f7; + --accent-2: #c084fc; + --accent-dark: #7c3aed; + --accent-rgb: 168, 85, 247; + --success: #10b981; + --warn: #f59e0b; + --danger: #ef4444; + --info: #38bdf8; + --shadow-sm: 0 1px 2px rgba(0,0,0,0.2); + --shadow-md: 0 4px 12px rgba(0,0,0,0.3); + --shadow-lg: 0 12px 32px rgba(0,0,0,0.5); + --radius: 8px; + --radius-lg: 12px; +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Inter", "Helvetica Neue", Arial, sans-serif; + font-size: 15px; + line-height: 1.5; + min-height: 100vh; +} +a { color: var(--accent-2); text-decoration: none; } +a:hover { color: var(--accent); } +button { font: inherit; cursor: pointer; border: none; background: transparent; color: inherit; } + +.cockpit { + max-width: 1280px; + margin: 0 auto; + padding: 1.5rem 1rem 6rem; + min-height: 100vh; +} + +/* ─── Topbar ────────────────────────────────────────────────────── */ +.topbar { display: flex; align-items: center; gap: 1rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--line); margin-bottom: 1.5rem; } +.brand { display: flex; align-items: center; gap: 0.75rem; } +.brand-icon { + width: 40px; height: 40px; + background: linear-gradient(135deg, var(--accent), var(--accent-dark)); + color: white; font-weight: 700; font-size: 22px; + border-radius: var(--radius); + display: grid; place-items: center; + box-shadow: var(--shadow-md); +} +.brand-text { display: flex; flex-direction: column; } +.brand-title { font-size: 1.2rem; font-weight: 600; } +.brand-title small { color: var(--text-muted); font-weight: 400; margin-left: 0.25rem; } +.brand-sub { font-size: 0.8rem; color: var(--text-muted); } +.spacer { flex: 1; } +.auth-state { display: flex; align-items: center; gap: 0.75rem; } +.auth-user { color: var(--text-muted); font-size: 0.85rem; } + +/* ─── Status block ─────────────────────────────────────────────── */ +.status-block { + background: linear-gradient(135deg, rgba(var(--accent-rgb),0.08), rgba(var(--accent-rgb),0.02)); + border: 1px solid rgba(var(--accent-rgb), 0.25); + border-radius: var(--radius-lg); + padding: 1rem 1.25rem; + margin-bottom: 1.5rem; +} +.status-title { font-size: 0.85rem; color: var(--accent-2); font-weight: 600; margin: 0 0 0.5rem; text-transform: uppercase; letter-spacing: 0.04em; } +.status-summary { font-size: 0.95rem; color: var(--text); } +.status-summary .dim { color: var(--text-muted); } + +/* ─── Top-level Space-Tabs ─────────────────────────────────────── */ +.space-tabs { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.5rem; + background: var(--bg-2); + padding: 0.5rem; + border-radius: var(--radius-lg); + border: 1px solid var(--line); + margin-bottom: 1.5rem; +} +.space-tab { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.85rem; + border-radius: var(--radius); + color: var(--text-muted); + transition: all 0.15s; + font-size: 0.95rem; + font-weight: 500; +} +.space-tab[aria-selected="true"] { + background: linear-gradient(135deg, rgba(var(--accent-rgb),0.18), rgba(var(--accent-rgb),0.05)); + color: var(--accent-2); + box-shadow: inset 0 0 0 1px rgba(var(--accent-rgb), 0.4); +} +.space-tab:hover { background: var(--bg-3); color: var(--text); } +.space-tab[aria-selected="true"]:hover { color: var(--accent-2); } +.space-icon { font-size: 1.1rem; } + +/* Each space-section is hidden unless data-active=true */ +.space { display: none; } +.space[data-active="true"] { display: block; } +.space-intro { margin-bottom: 1.25rem; } +.space-intro h2 { margin: 0 0 0.4rem; font-size: 1.15rem; } +.space-intro p { margin: 0; color: var(--text-muted); font-size: 0.9rem; } + +/* ─── Folder tabs (Dokumente) ──────────────────────────────────── */ +.folder-tabs { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.5rem; + background: var(--bg-2); + padding: 0.5rem; + border-radius: var(--radius-lg); + border: 1px solid var(--line); + margin-bottom: 1rem; +} +.folder-tab { + display: flex; flex-direction: column; align-items: center; gap: 0.25rem; + padding: 0.75rem; border-radius: var(--radius); + color: var(--text-muted); + transition: background 0.15s, color 0.15s; +} +.folder-tab[aria-selected="true"] { + background: rgba(var(--accent-rgb), 0.12); + color: var(--text); + box-shadow: inset 0 -2px 0 var(--accent); +} +.folder-tab:hover { background: var(--bg-3); color: var(--text); } +.folder-icon { font-size: 1.25rem; } +.folder-label { font-size: 0.85rem; font-weight: 500; } +.folder-count { + font-size: 0.7rem; background: var(--bg-3); color: var(--text-muted); + padding: 0.1rem 0.5rem; border-radius: 10px; min-width: 24px; text-align: center; +} +.folder-tab[aria-selected="true"] .folder-count { + background: rgba(var(--accent-rgb), 0.25); + color: var(--accent-2); +} + +.folder-body { + background: var(--bg-2); border: 1px solid var(--line); + border-radius: var(--radius-lg); padding: 1.25rem; +} +.folder-help { font-size: 0.85rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.5; } + +.upload-zone { + border: 2px dashed var(--line); border-radius: var(--radius-lg); + padding: 2rem 1rem; text-align: center; + transition: border-color 0.15s, background 0.15s; + cursor: pointer; margin-bottom: 1rem; +} +.upload-zone:hover, .upload-zone:focus, .upload-zone[data-dragover="true"] { + border-color: var(--accent); background: rgba(var(--accent-rgb), 0.05); + outline: none; +} +.upload-cta { display: flex; flex-direction: column; align-items: center; gap: 0.4rem; } +.upload-icon { font-size: 1.5rem; opacity: 0.6; } +.upload-text { font-size: 1rem; color: var(--text); } +.upload-hint { font-size: 0.8rem; color: var(--text-muted); } + +.upload-progress { margin-top: 1rem; display: flex; flex-direction: column; gap: 0.5rem; } +.upload-progress-row { + display: flex; align-items: center; gap: 0.75rem; + padding: 0.5rem 0.75rem; background: var(--bg-3); + border-radius: var(--radius); font-size: 0.85rem; +} +.upload-progress-row .upload-bar { flex: 1; height: 4px; background: var(--bg); border-radius: 2px; overflow: hidden; } +.upload-progress-row .upload-bar-fill { height: 100%; background: var(--accent); width: 0%; transition: width 0.2s; } + +/* ─── Document list ────────────────────────────────────────────── */ +.doc-list { list-style: none; padding: 0; margin: 0; } +.doc-row { display: flex; align-items: center; gap: 1rem; padding: 0.75rem; border-bottom: 1px solid var(--line); transition: background 0.1s; } +.doc-row:hover { background: var(--bg-3); } +.doc-row.is-disabled { opacity: 0.55; } +.doc-meta { flex: 1; min-width: 0; } +.doc-name { font-size: 0.95rem; color: var(--text); margin: 0; word-break: break-word; } +.doc-info { font-size: 0.75rem; color: var(--text-muted); margin-top: 0.15rem; } +.doc-info .ok { color: var(--success); } +.doc-info .pending { color: var(--warn); } +.doc-info .err { color: var(--danger); } +.doc-actions { display: flex; gap: 0.5rem; align-items: center; } + +.toggle { + position: relative; width: 42px; height: 22px; + background: var(--bg-3); border-radius: 11px; + cursor: pointer; transition: background 0.15s; flex-shrink: 0; +} +.toggle::after { + content: ""; position: absolute; top: 2px; left: 2px; + width: 18px; height: 18px; background: var(--text-muted); + border-radius: 50%; transition: left 0.15s, background 0.15s; +} +.toggle[data-on="true"] { background: rgba(var(--accent-rgb), 0.4); } +.toggle[data-on="true"]::after { left: 22px; background: var(--accent); } + +.btn-icon { + width: 32px; height: 32px; + display: grid; place-items: center; + border-radius: var(--radius); + color: var(--text-muted); + transition: background 0.1s, color 0.1s; +} +.btn-icon:hover { background: var(--bg-3); color: var(--danger); } +.btn-icon-x { + width: 28px; height: 28px; + border-radius: 50%; + color: var(--text-muted); + transition: background 0.1s, color 0.1s; +} +.btn-icon-x:hover { background: var(--bg-3); color: var(--text); } + +.doc-empty, .empty-state { + padding: 1.5rem; text-align: center; + color: var(--text-muted); font-size: 0.9rem; +} + +/* ─── Buttons ──────────────────────────────────────────────────── */ +.btn-primary { + background: var(--accent); color: white; + padding: 0.6rem 1.25rem; border-radius: var(--radius); + font-weight: 500; transition: background 0.15s; +} +.btn-primary:hover { background: var(--accent-dark); } +.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-secondary { + background: var(--bg-3); color: var(--text); + padding: 0.5rem 1rem; border-radius: var(--radius); + font-size: 0.85rem; border: 1px solid var(--line); +} +.btn-secondary:hover { background: var(--bg-4); } +.btn-ghost { + color: var(--text-muted); padding: 0.4rem 0.8rem; + border-radius: var(--radius); font-size: 0.85rem; +} +.btn-ghost:hover { background: var(--bg-3); color: var(--text); } + +/* ─── Login screen ─────────────────────────────────────────────── */ +.login-screen { display: grid; place-items: center; min-height: 60vh; } +.login-card { width: 100%; max-width: 380px; background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 2rem; box-shadow: var(--shadow-lg); } +.login-card h2 { margin: 0 0 0.5rem; font-size: 1.3rem; } +.login-hint { color: var(--text-muted); font-size: 0.9rem; margin: 0 0 1.5rem; } +.login-form { display: flex; flex-direction: column; gap: 1rem; } +.login-form label { display: flex; flex-direction: column; gap: 0.35rem; font-size: 0.85rem; color: var(--text-muted); } +.login-form input { padding: 0.6rem 0.8rem; background: var(--bg); color: var(--text); border: 1px solid var(--line); border-radius: var(--radius); font-size: 0.95rem; } +.login-form input:focus { outline: none; border-color: var(--accent); } +.login-error { background: rgba(239, 68, 68, 0.1); border: 1px solid var(--danger); color: var(--danger); padding: 0.6rem 0.8rem; border-radius: var(--radius); font-size: 0.85rem; } + +/* ─── Klausur cards ────────────────────────────────────────────── */ +.klausur-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 0.75rem; } +.klausur-card { + background: var(--bg-2); border: 1px solid var(--line); + border-radius: var(--radius); padding: 1rem; + display: flex; flex-direction: column; gap: 0.6rem; + transition: border-color 0.15s, transform 0.1s; + cursor: pointer; +} +.klausur-card:hover { border-color: var(--accent); transform: translateY(-1px); } +.klausur-card-header { display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem; } +.klausur-card-num { background: var(--accent); color: white; font-weight: 700; font-size: 0.75rem; padding: 0.15rem 0.5rem; border-radius: 10px; flex-shrink: 0; } +.klausur-card-name { font-weight: 600; font-size: 0.95rem; flex: 1; word-break: break-word; } +.klausur-card-topics { display: flex; flex-wrap: wrap; gap: 0.3rem; } +.klausur-card-topic { background: rgba(var(--accent-rgb), 0.12); color: var(--accent-2); padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.7rem; cursor: pointer; transition: background 0.1s; } +.klausur-card-topic:hover { background: rgba(var(--accent-rgb), 0.25); } +.klausur-card-notes { color: var(--text-muted); font-size: 0.8rem; font-style: italic; } +.klausur-card-cta { margin-top: 0.25rem; font-size: 0.85rem; color: var(--accent-2); display: flex; align-items: center; gap: 0.3rem; } +.klausur-card-actions { display: flex; gap: 0.4rem; margin-top: 0.4rem; flex-wrap: wrap; } +.klausur-card-mini { + font-size: 0.75rem; padding: 0.35rem 0.6rem; border-radius: 4px; + background: var(--bg-4); color: var(--text-muted); + border: 1px solid var(--line); + transition: all 0.1s; +} +.klausur-card-mini:hover { background: var(--bg-3); color: var(--accent-2); border-color: var(--accent); } + +/* ─── Lernen: Heatmap ──────────────────────────────────────────── */ +.heatmap-block { background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 1.25rem; margin-bottom: 1.5rem; } +.heatmap-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem; } +.heatmap-title { margin: 0; font-size: 1rem; } +.heatmap-legend { display: flex; align-items: center; gap: 0.4rem; font-size: 0.75rem; color: var(--text-muted); flex-wrap: wrap; } +.legend-cell { display: inline-block; width: 18px; height: 12px; border-radius: 2px; } +.legend-cell[data-level="0"] { background: var(--bg-4); } +.legend-cell[data-level="2"] { background: rgba(245, 158, 11, 0.55); } +.legend-cell[data-level="4"] { background: rgba(16, 185, 129, 0.6); } +.legend-cell[data-level="5"] { background: rgba(16, 185, 129, 1); } + +.heatmap-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 0.5rem; } +.heatmap-cell { + background: var(--bg-3); border: 1px solid var(--line); + border-radius: var(--radius); padding: 0.75rem; + display: flex; flex-direction: column; gap: 0.3rem; + position: relative; overflow: hidden; + transition: transform 0.1s, border-color 0.1s; + cursor: pointer; +} +.heatmap-cell:hover { transform: translateY(-1px); border-color: var(--accent); } +.heatmap-cell .hc-bar { + position: absolute; bottom: 0; left: 0; right: 0; height: 3px; + background: var(--bg-4); +} +.heatmap-cell .hc-bar-fill { + height: 100%; background: linear-gradient(to right, var(--warn), var(--success)); + transition: width 0.3s; +} +.heatmap-cell .hc-name { font-size: 0.85rem; color: var(--text); font-weight: 500; line-height: 1.3; } +.heatmap-cell .hc-meta { font-size: 0.7rem; color: var(--text-muted); display: flex; justify-content: space-between; } +.heatmap-cell[data-level="0"] .hc-bar-fill { background: var(--bg-4); width: 0%; } +.heatmap-cell[data-level="1"] .hc-bar-fill { width: 20%; } +.heatmap-cell[data-level="2"] .hc-bar-fill { width: 40%; } +.heatmap-cell[data-level="3"] .hc-bar-fill { width: 60%; } +.heatmap-cell[data-level="4"] .hc-bar-fill { width: 80%; } +.heatmap-cell[data-level="5"] .hc-bar-fill { width: 100%; background: var(--success); } + +/* ─── Lernen: Topic picker ─────────────────────────────────────── */ +.topic-picker { background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 1.25rem; margin-bottom: 1.5rem; } +.topic-picker-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; gap: 0.5rem; flex-wrap: wrap; } +.topic-picker-head h3 { margin: 0; font-size: 1rem; } +.select-mini { + background: var(--bg-3); color: var(--text); + border: 1px solid var(--line); border-radius: var(--radius); + padding: 0.4rem 0.6rem; font-size: 0.85rem; +} +.select-mini:focus { outline: none; border-color: var(--accent); } +.topic-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.5rem; } +.topic-pill { + background: var(--bg-3); border: 1px solid var(--line); + border-radius: var(--radius); padding: 0.6rem 0.85rem; + font-size: 0.85rem; color: var(--text); + cursor: pointer; transition: all 0.1s; + display: flex; flex-direction: column; gap: 0.25rem; + text-align: left; +} +.topic-pill:hover { border-color: var(--accent); background: var(--bg-4); } +.topic-pill .tp-label { font-weight: 500; } +.topic-pill .tp-meta { font-size: 0.7rem; color: var(--text-muted); } +.topic-pill[data-mastered="true"] { border-color: var(--success); } +.topic-pill .tp-stars { color: var(--warn); font-size: 0.75rem; letter-spacing: 0.05em; } + +/* ─── Minigame Launcher ────────────────────────────────────────── */ +.minigame-launcher { + background: linear-gradient(135deg, rgba(var(--accent-rgb),0.06), var(--bg-2)); + border: 1px solid var(--accent); + border-radius: var(--radius-lg); + padding: 1.25rem; + margin-bottom: 1.5rem; + animation: fadein 0.2s ease-out; +} +.launcher-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; font-size: 0.95rem; } +.launcher-head strong { color: var(--accent-2); } +.launcher-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.75rem; } +.minigame-card { + background: var(--bg-3); border: 1px solid var(--line); + border-radius: var(--radius); padding: 1rem; + display: flex; flex-direction: column; gap: 0.4rem; + text-align: left; transition: all 0.1s; +} +.minigame-card:hover { border-color: var(--accent); transform: translateY(-1px); background: var(--bg-4); } +.mg-icon { font-size: 1.3rem; } +.mg-title { font-weight: 600; font-size: 0.95rem; color: var(--text); } +.mg-desc { font-size: 0.8rem; color: var(--text-muted); line-height: 1.35; } +.mg-tag { font-size: 0.7rem; color: var(--accent-2); margin-top: 0.25rem; } + +/* ─── Minigame Stage ──────────────────────────────────────────── */ +.minigame-stage { + background: var(--bg-2); border: 1px solid var(--line); + border-radius: var(--radius-lg); padding: 1.5rem; + animation: fadein 0.2s ease-out; +} +.stage-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; gap: 0.5rem; flex-wrap: wrap; } +.stage-title { margin: 0; font-size: 1.05rem; font-weight: 600; } +.stage-progress { color: var(--text-muted); font-size: 0.85rem; } +.stage-progress-bar { height: 4px; background: var(--bg-4); border-radius: 2px; overflow: hidden; margin-bottom: 1rem; } +.stage-progress-bar-fill { height: 100%; background: var(--accent); transition: width 0.3s; } + +.stage-loader { padding: 3rem 1rem; text-align: center; color: var(--text-muted); } +.stage-spinner { + display: inline-block; width: 28px; height: 28px; + border: 3px solid var(--bg-4); border-top-color: var(--accent); + border-radius: 50%; animation: spin 0.7s linear infinite; + margin-bottom: 0.8rem; +} +@keyframes spin { to { transform: rotate(360deg); } } +@keyframes fadein { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } + +/* MCQ-style question block — used by Diagnose + Klausur quiz */ +.qa-card { background: var(--bg-3); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 1.25rem; } +.qa-q { font-size: 1.05rem; font-weight: 500; margin: 0 0 1rem; line-height: 1.5; } +.qa-options { display: flex; flex-direction: column; gap: 0.5rem; } +.qa-option { + text-align: left; padding: 0.75rem 1rem; + background: var(--bg); border: 1px solid var(--line); + border-radius: var(--radius); transition: all 0.1s; + display: flex; gap: 0.75rem; align-items: flex-start; +} +.qa-option:not(:disabled):hover { border-color: var(--accent); background: var(--bg-4); } +.qa-option:disabled { cursor: default; } +.qa-option-letter { font-weight: 600; color: var(--text-muted); flex-shrink: 0; } +.qa-option[data-state="correct"] { border-color: var(--success); background: rgba(16, 185, 129, 0.1); } +.qa-option[data-state="wrong"] { border-color: var(--danger); background: rgba(239, 68, 68, 0.1); } +.qa-option[data-state="reveal-correct"] { border-color: var(--success); } +.qa-feedback { + margin-top: 1rem; padding: 0.85rem 1rem; border-radius: var(--radius); + background: var(--bg); border-left: 3px solid var(--accent); + font-size: 0.9rem; line-height: 1.5; +} +.qa-feedback.correct { border-left-color: var(--success); } +.qa-feedback.wrong { border-left-color: var(--danger); } +.qa-feedback strong { color: var(--accent-2); } +.qa-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; } + +/* Klinikfall stage */ +.fall-patient { background: rgba(56, 189, 248, 0.1); border-left: 3px solid var(--info); padding: 1rem; border-radius: var(--radius); margin-bottom: 1rem; line-height: 1.6; } +.fall-stage-num { color: var(--info); font-weight: 600; font-size: 0.85rem; margin-bottom: 0.25rem; } +.fall-learning { + margin-top: 0.75rem; padding: 0.6rem 0.85rem; + background: rgba(168, 85, 247, 0.08); + border-left: 3px solid var(--accent); + border-radius: var(--radius); + font-size: 0.85rem; color: var(--text-muted); +} +.fall-learning strong { color: var(--accent-2); } + +/* Stimmt-das? */ +.stimmt-grid { display: flex; flex-direction: column; gap: 0.75rem; } +.stimmt-card { + background: var(--bg-3); border: 1px solid var(--line); + border-radius: var(--radius); padding: 1rem; +} +.stimmt-statement { font-size: 0.95rem; margin-bottom: 0.75rem; line-height: 1.5; } +.stimmt-buttons { display: flex; gap: 0.5rem; } +.stimmt-btn { + flex: 1; padding: 0.5rem 1rem; + background: var(--bg); border: 1px solid var(--line); + border-radius: var(--radius); font-weight: 500; + transition: all 0.1s; +} +.stimmt-btn:not(:disabled):hover { border-color: var(--accent); background: var(--bg-4); } +.stimmt-btn[data-state="correct"] { border-color: var(--success); background: rgba(16, 185, 129, 0.15); color: var(--success); } +.stimmt-btn[data-state="wrong"] { border-color: var(--danger); background: rgba(239, 68, 68, 0.15); color: var(--danger); } +.stimmt-explain { margin-top: 0.5rem; font-size: 0.85rem; color: var(--text-muted); padding-left: 0.6rem; border-left: 2px solid var(--line); } +.stimmt-explain.correct { border-left-color: var(--success); } +.stimmt-explain.wrong { border-left-color: var(--danger); } +.trap-tag { display: inline-block; padding: 0.1rem 0.4rem; background: rgba(245, 158, 11, 0.15); color: var(--warn); border-radius: 3px; font-size: 0.7rem; margin-right: 0.4rem; } + +/* Summary card for end of minigame */ +.summary-card { + background: linear-gradient(135deg, rgba(var(--accent-rgb),0.1), var(--bg-3)); + border: 1px solid var(--accent); + border-radius: var(--radius-lg); + padding: 1.5rem; + text-align: center; +} +.summary-stars { + font-size: 2rem; letter-spacing: 0.1em; margin-bottom: 0.5rem; + color: var(--warn); +} +.summary-text { font-size: 1rem; margin-bottom: 0.25rem; } +.summary-meta { color: var(--text-muted); font-size: 0.85rem; margin-bottom: 1rem; } +.summary-actions { display: flex; gap: 0.5rem; justify-content: center; flex-wrap: wrap; } + +/* ─── Persona-Form ─────────────────────────────────────────────── */ +.persona-form { background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 1.25rem; } +.persona-label { display: flex; flex-direction: column; gap: 0.5rem; font-size: 0.85rem; color: var(--text-muted); margin-bottom: 1rem; } +.persona-form textarea { + background: var(--bg); color: var(--text); + border: 1px solid var(--line); border-radius: var(--radius); + padding: 0.75rem 1rem; font-family: inherit; font-size: 0.95rem; + line-height: 1.5; resize: vertical; min-height: 200px; +} +.persona-form textarea:focus { outline: none; border-color: var(--accent); } +.persona-actions { display: flex; justify-content: space-between; align-items: center; gap: 1rem; } +.persona-status { color: var(--text-muted); font-size: 0.85rem; } +.persona-status.saved { color: var(--success); } +.hint-list { color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin: 0.5rem 0 1rem; padding-left: 1.5rem; } +.hint-list li { margin-bottom: 0.25rem; } + +/* ─── Telegram-Pairing Block ──────────────────────────────────── */ +.telegram-block { + background: var(--bg-2); border: 1px solid var(--line); + border-radius: var(--radius-lg); padding: 1.25rem; margin-top: 1.5rem; +} +.tg-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; gap: 0.5rem; } +.tg-head h3 { margin: 0; font-size: 1rem; } +.tg-hint { color: var(--text-muted); font-size: 0.9rem; margin: 0 0 1rem; line-height: 1.5; } +.tg-badge { + font-size: 0.75rem; padding: 0.2rem 0.6rem; border-radius: 10px; + background: var(--bg-3); color: var(--text-muted); + border: 1px solid var(--line); +} +.tg-badge.linked { background: rgba(16,185,129,0.15); color: var(--success); border-color: var(--success); } +.tg-badge.unlinked { background: var(--bg-3); color: var(--text-muted); } +.tg-link-area { display: flex; gap: 0.5rem; flex-wrap: wrap; } +.tg-code-display { margin-top: 1rem; } +.tg-code-line { + background: var(--bg); border: 1px solid var(--accent); + border-radius: var(--radius); padding: 0.85rem 1rem; + font-size: 1.1rem; letter-spacing: 0.05em; + margin: 0.5rem 0; + user-select: all; + display: flex; justify-content: center; +} +.tg-code-line code { color: var(--accent-2); font-weight: 600; font-size: 1.15rem; letter-spacing: 0.1em; } +.tg-code-hint { font-size: 0.8rem; color: var(--text-muted); margin: 0.25rem 0 0; } + +/* ─── Chat Dock (resizable + MD) ──────────────────────────────── */ +.chat-dock { + position: fixed; + bottom: 1rem; right: 1rem; + width: 460px; height: 640px; + min-width: 340px; min-height: 420px; + max-width: calc(100vw - 2rem); + max-height: calc(100vh - 2rem); + background: var(--bg-2); border: 1px solid var(--line); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + display: none; flex-direction: column; + z-index: 100; +} +.chat-dock[data-open="true"] { display: flex; } +.dock-resize { + position: absolute; + top: 0; left: 0; + width: 14px; height: 14px; + cursor: nwse-resize; + background: linear-gradient(135deg, transparent 35%, var(--text-dim) 35%, var(--text-dim) 50%, transparent 50%); + border-top-left-radius: var(--radius-lg); + z-index: 1; +} +.dock-resize:hover { background-color: rgba(var(--accent-rgb), 0.1); } +.dock-head { padding: 0.75rem 1rem; border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 0.5rem; } +.dock-title { font-weight: 600; } +.dock-sub { color: var(--text-muted); font-size: 0.85rem; flex: 1; } +.dock-reset, .dock-collapse { width: 28px; height: 28px; border-radius: var(--radius); color: var(--text-muted); } +.dock-reset:hover, .dock-collapse:hover { background: var(--bg-3); color: var(--text); } +.dock-box { flex: 1; padding: 0.75rem 1rem; overflow-y: auto; display: flex; flex-direction: column; gap: 0.75rem; } + +.dock-msg { + max-width: 90%; + padding: 0.7rem 0.9rem; + border-radius: var(--radius); + font-size: 0.92rem; + line-height: 1.55; + word-break: break-word; +} +.dock-msg.user { align-self: flex-end; background: var(--accent-dark); color: white; white-space: pre-wrap; } +.dock-msg.assistant { align-self: flex-start; background: var(--bg-3); color: var(--text); } +.dock-msg.assistant.typing { font-style: italic; opacity: 0.7; } + +/* MD content inside chat */ +.dock-md p { margin: 0 0 0.6rem; } +.dock-md p:last-child { margin-bottom: 0; } +.dock-md h1, .dock-md h2, .dock-md h3 { margin: 0.6rem 0 0.4rem; line-height: 1.3; } +.dock-md h1 { font-size: 1.05rem; color: var(--accent-2); } +.dock-md h2 { font-size: 1rem; color: var(--accent-2); } +.dock-md h3 { font-size: 0.95rem; color: var(--text); } +.dock-md ul, .dock-md ol { margin: 0.4rem 0 0.6rem; padding-left: 1.4rem; } +.dock-md li { margin-bottom: 0.2rem; } +.dock-md code { background: var(--bg); padding: 0.1rem 0.35rem; border-radius: 3px; font-size: 0.85em; color: var(--accent-2); } +.dock-md pre { background: var(--bg); padding: 0.7rem 0.9rem; border-radius: var(--radius); overflow-x: auto; margin: 0.5rem 0; } +.dock-md pre code { background: none; padding: 0; color: var(--text); } +.dock-md strong { color: var(--text); } +.dock-md em { color: var(--text-muted); font-style: italic; } +.dock-md a { color: var(--accent-2); text-decoration: underline; } +.dock-md table.md-table { border-collapse: collapse; margin: 0.5rem 0; font-size: 0.85em; width: auto; } +.dock-md table.md-table th, .dock-md table.md-table td { border: 1px solid var(--line); padding: 0.3rem 0.6rem; } +.dock-md table.md-table th { background: var(--bg); } +.dock-md blockquote { border-left: 3px solid var(--accent); padding-left: 0.8rem; margin: 0.5rem 0; color: var(--text-muted); } + +.dock-msg .sources { + margin-top: 0.6rem; padding-top: 0.5rem; border-top: 1px solid var(--line); + font-size: 0.72rem; color: var(--text-muted); +} +.dock-msg .sources .src-tag { + display: inline-block; background: rgba(var(--accent-rgb), 0.15); color: var(--accent-2); + padding: 0.1rem 0.4rem; border-radius: 3px; margin: 0.15rem 0.25rem 0 0; font-size: 0.7rem; +} + +/* Inline structured renderer (quiz/flashcards/case in chat) */ +.dock-struct { + background: var(--bg); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 0.85rem; + margin-top: 0.5rem; +} +.dock-struct .ds-head { font-weight: 600; color: var(--accent-2); margin-bottom: 0.4rem; font-size: 0.85rem; } +.dock-struct .ds-q { font-size: 0.9rem; margin-bottom: 0.5rem; } +.dock-struct .ds-options { display: flex; flex-direction: column; gap: 0.3rem; } +.dock-struct .ds-option { + text-align: left; padding: 0.4rem 0.6rem; + background: var(--bg-3); border: 1px solid var(--line); + border-radius: 4px; font-size: 0.85rem; +} +.dock-struct .ds-option:hover:not(:disabled) { border-color: var(--accent); } +.dock-struct .ds-option[data-state="correct"] { border-color: var(--success); background: rgba(16, 185, 129, 0.1); } +.dock-struct .ds-option[data-state="wrong"] { border-color: var(--danger); background: rgba(239, 68, 68, 0.1); } + +.dock-form { border-top: 1px solid var(--line); padding: 0.5rem; display: flex; gap: 0.5rem; } +.dock-form textarea { + flex: 1; resize: none; + background: var(--bg); color: var(--text); + border: 1px solid var(--line); border-radius: var(--radius); + padding: 0.5rem 0.7rem; font-size: 0.9rem; + max-height: 140px; min-height: 36px; + font-family: inherit; +} +.dock-form textarea:focus { outline: none; border-color: var(--accent); } +.btn-send { background: var(--accent); color: white; width: 40px; border-radius: var(--radius); font-weight: 700; font-size: 1.1rem; } +.btn-send:hover { background: var(--accent-dark); } + +.dock-open { + position: fixed; bottom: 1rem; right: 1rem; + background: var(--accent); color: white; + padding: 0.7rem 1.2rem; border-radius: 24px; + font-weight: 500; box-shadow: var(--shadow-lg); + display: flex; align-items: center; gap: 0.5rem; + z-index: 99; +} +.dock-open:hover { background: var(--accent-dark); } +.dock-open[hidden] { display: none; } +.dock-open-dot { width: 8px; height: 8px; background: var(--success); border-radius: 50%; display: inline-block; } + +/* ─── Footer ───────────────────────────────────────────────────── */ +.footer { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--line); text-align: center; font-size: 0.8rem; color: var(--text-muted); } + +/* ─── Toasts ───────────────────────────────────────────────────── */ +.toast-stack { position: fixed; top: 1rem; right: 1rem; display: flex; flex-direction: column; gap: 0.5rem; z-index: 200; pointer-events: none; } +.toast { padding: 0.6rem 1rem; background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-md); color: var(--text); font-size: 0.85rem; pointer-events: auto; animation: toast-in 0.2s ease-out; } +.toast.error { border-color: var(--danger); color: var(--danger); } +.toast.success { border-color: var(--success); color: var(--success); } +@keyframes toast-in { from { transform: translateX(20px); opacity: 0; } to { transform: translateX(0); opacity: 1; } } + +/* Mobile */ +@media (max-width: 800px) { + .space-tabs, .folder-tabs { grid-template-columns: repeat(2, 1fr); } + .space-tab { font-size: 0.85rem; padding: 0.7rem 0.4rem; } + .chat-dock { width: calc(100vw - 2rem); height: calc(100vh - 6rem); right: 1rem; bottom: 1rem; } + .dock-resize { display: none; } +} diff --git a/src/cockpit-overlay/cockpit.js b/src/cockpit-overlay/cockpit.js new file mode 100644 index 0000000..1186142 --- /dev/null +++ b/src/cockpit-overlay/cockpit.js @@ -0,0 +1,1099 @@ +/** + * Luna Cockpit v3 — Tutor-Mode UI for physio-tutor (Hanna). + * + * Spaces (top-tabs): Dokumente · Klausuren · Lernen · Steuern + * Lernen-Tab: heatmap + topic-picker + 6 minigames (diagnose / klinikfall / + * stimmt-das / classic-quiz / flashcards / explain) + * Chat-Dock: resizable, MD-rendered, inline quiz/flashcard widgets, source-footer. + */ +(() => { + const API = window.__API_BASE__ || "https://api.qognio.com"; + const SLUG = window.__BOT_SLUG__ || "physio-tutor"; + const BOT_KEY = window.__LUNA_KEY__; + const BOT_ID = window.__BOT_ID__; + const LS_TOKEN = "luna.pb.token"; + const LS_USER = "luna.pb.user"; + const LS_CHAT = "luna.cockpit.chat"; + const LS_DOCK_SIZE = "luna.cockpit.dock-size"; + + const FOLDER_HELP = { + curriculum: + "Was die Uni / Schule offiziell vorgibt: Studienplan, Lernzielkatalog, " + + "Klausurplan. Lädt du hier z.B. inhaltexamen2026.pdf hoch, " + + "kann Luna die Klausur-Themen daraus auslesen.", + official: + "Skripte und Folien deiner Dozent:innen — also alles, was die " + + "Lehrenden offiziell ausgegeben haben.", + own: + "Deine eigenen Notizen, Mitschriften, Markierungen. Luna lernt deinen " + + "persönlichen Stand kennen.", + role: + "Schwerpunkt-Hinweise: aktuelle Lerneinheit, anstehende Termine, " + + "Fokus-Themen — beeinflusst, worauf Luna sich konzentriert.", + }; + + const $ = (sel, root) => (root || document).querySelector(sel); + const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel)); + + // ─── State ────────────────────────────────────────────────────── + let token = localStorage.getItem(LS_TOKEN); + let user = (() => { try { return JSON.parse(localStorage.getItem(LS_USER) || "null"); } catch { return null; } })(); + let activeSpace = "dokumente"; + let activeFolder = "curriculum"; + let docs = []; + let klausuren = []; + let mastery = []; + let curricula = null; + let activeMinigame = null; + let chatHistory = (() => { try { return JSON.parse(localStorage.getItem(LS_CHAT) || "[]"); } catch { return []; } })(); + + // ─── Toasts ───────────────────────────────────────────────────── + function toast(msg, kind = "info") { + const stack = $("#toast-stack"); + const el = document.createElement("div"); + el.className = `toast ${kind}`; + el.textContent = msg; + stack.appendChild(el); + setTimeout(() => { el.style.opacity = "0"; setTimeout(() => el.remove(), 200); }, 3500); + } + + // ─── MD renderer (ported from core/app.js) ───────────────────── + function renderMD(md) { + if (!md) return ""; + let s = md; + s = s.replace(/&/g, "&").replace(//g, ">"); + s = s.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) => `
${code}
`); + s = s.replace(/`([^`\n]+)`/g, "$1"); + // GFM tables + s = s.replace(/(?:^|\n)((?:\|[^\n]*\|[ \t]*\n){2,})/g, (block, content) => { + const lines = content.trim().split("\n"); + if (lines.length < 2) return block; + const sep = lines[1]; + if (!/^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/.test(sep)) return block; + const parseRow = (ln) => ln.replace(/^\|/, "").replace(/\|\s*$/, "").split("|").map(c => c.trim()); + const header = parseRow(lines[0]); + const aligns = parseRow(sep).map(s => /^:-+:$/.test(s) ? "center" : /-+:$/.test(s) ? "right" : "left"); + const rows = lines.slice(2).map(parseRow); + let html = '\n'; + header.forEach((h, i) => { html += ``; }); + html += ""; + rows.forEach(r => { + html += ""; + for (let i = 0; i < Math.max(r.length, header.length); i++) { + html += ``; + } + html += ""; + }); + html += "
${h}
${r[i] || ""}
\n"; + return html; + }); + s = s.replace(/\*\*([^*\n]+)\*\*/g, "$1"); + s = s.replace(/__([^_\n]+)__/g, "$1"); + s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2"); + s = s.replace(/^### (.+)$/gm, "

$1

"); + s = s.replace(/^## (.+)$/gm, "

$1

"); + s = s.replace(/^# (.+)$/gm, "

$1

"); + s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '$1'); + s = s.replace(/(?:^|\n)((?:[*\-] .+\n?)+)/g, (m) => { + const items = m.trim().split("\n").map(ln => ln.replace(/^[*\-] /, "")).map(li => `
  • ${li}
  • `).join(""); + return "\n
      " + items + "
    "; + }); + s = s.replace(/(?:^|\n)((?:\d+\. .+\n?)+)/g, (m) => { + const items = m.trim().split("\n").map(ln => ln.replace(/^\d+\. /, "")).map(li => `
  • ${li}
  • `).join(""); + return "\n
      " + items + "
    "; + }); + s = s.split(/\n{2,}/).map(block => { + if (/^<(h\d|ul|ol|pre|blockquote|table)/.test(block.trim())) return block; + return "

    " + block.replace(/\n/g, "
    ") + "

    "; + }).join("\n"); + return s; + } + + function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">","\"":""","'":"'"})[c]); } + function escapeAttr(s) { return escapeHtml(s).replace(/\n/g, " "); } + + // ─── Login ────────────────────────────────────────────────────── + function showLogin() { $("#login-screen").hidden = false; $("#main").hidden = true; $("#dock-open").hidden = true; } + function showApp() { + $("#login-screen").hidden = true; + $("#main").hidden = false; + $("#dock-open").hidden = false; + if (user) { $("#auth-user").textContent = user.email; $("#auth-logout").hidden = false; } + } + $("#login-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const email = $("#login-email").value.trim(); + const pw = $("#login-pw").value; + const errBox = $("#login-error"); + errBox.hidden = true; + try { + const r = await fetch(`${API}/api/collections/users/auth-with-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identity: email, password: pw }), + }); + if (!r.ok) { + const data = await r.json().catch(() => ({})); + throw new Error(data.message || `Login fehlgeschlagen (${r.status})`); + } + const data = await r.json(); + token = data.token; + user = { id: data.record.id, email: data.record.email, name: data.record.name, organization: data.record.organization }; + localStorage.setItem(LS_TOKEN, token); + localStorage.setItem(LS_USER, JSON.stringify(user)); + showApp(); + await refresh(); + } catch (e) { + errBox.textContent = e.message || "Login fehlgeschlagen"; + errBox.hidden = false; + } + }); + $("#auth-logout").addEventListener("click", () => { + localStorage.removeItem(LS_TOKEN); localStorage.removeItem(LS_USER); + token = null; user = null; showLogin(); + }); + + // ─── API helper ──────────────────────────────────────────────── + async function api(path, opts = {}) { + const r = await fetch(`${API}${path}`, { + ...opts, + headers: { + ...(opts.headers || {}), + Authorization: `Bearer ${token}`, + ...(opts.body && !(opts.body instanceof FormData) ? { "Content-Type": "application/json" } : {}), + }, + }); + if (r.status === 401) { showLogin(); throw new Error("session_expired"); } + if (!r.ok) { + const text = await r.text().catch(() => ""); + throw new Error(`API ${path} → ${r.status}: ${text.slice(0, 120)}`); + } + return r.json(); + } + + // ─── Loaders ─────────────────────────────────────────────────── + async function loadCurricula() { + if (curricula) return curricula; + try { + const r = await fetch("/curricula.json", { cache: "no-cache" }); + if (r.ok) curricula = await r.json(); + } catch (e) { console.warn("curricula", e); } + return curricula; + } + async function loadDocs() { + const data = await api(`/api/tutor/documents?bot=${BOT_ID}`); + docs = data.items || []; + renderCounts(); + renderDocList(); + renderStatus(); + } + async function loadKlausuren() { + try { + const data = await api(`/api/tutor/klausuren?bot=${BOT_ID}`); + klausuren = data.items || []; + } catch (e) { console.warn("klausuren", e); klausuren = []; } + renderKlausuren(); + } + async function loadMastery() { + try { + const data = await api(`/api/tutor/mastery?bot=${BOT_ID}`); + mastery = data.items || []; + } catch (e) { mastery = []; } + renderHeatmap(); + renderTopicGrid(); // re-render to update star indicators + } + async function loadPersona() { + try { + const data = await api(`/api/tutor/persona?bot=${BOT_ID}`); + $("#persona-overrides").value = data.overrides || ""; + } catch { /* */ } + } + + // ─── Upload ──────────────────────────────────────────────────── + async function uploadFile(file) { + if (file.size > 20 * 1024 * 1024) { toast(`${file.name}: zu groß (max 20 MB)`, "error"); return; } + const mime = file.type || guessMime(file.name); + const row = document.createElement("div"); + row.className = "upload-progress-row"; + row.innerHTML = `${escapeHtml(file.name)} +
    + `; + $("#upload-progress").hidden = false; + $("#upload-progress").appendChild(row); + const fill = row.querySelector(".upload-bar-fill"); + const stat = row.querySelector(".upload-status"); + try { + const presign = await api("/api/tutor/upload-url", { + method: "POST", + body: JSON.stringify({ bot: BOT_ID, folder: activeFolder, filename: file.name, mime, size: file.size }), + }); + fill.style.width = "20%"; stat.textContent = "S3…"; + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("PUT", presign.url, true); + xhr.setRequestHeader("Content-Type", presign.content_type); + xhr.upload.onprogress = (e) => { if (e.lengthComputable) fill.style.width = (20 + (e.loaded / e.total) * 60) + "%"; }; + xhr.onload = () => xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(`PUT ${xhr.status}`)); + xhr.onerror = () => reject(new Error("network")); + xhr.send(file); + }); + fill.style.width = "85%"; stat.textContent = "registriere…"; + await api("/api/tutor/documents", { + method: "POST", + body: JSON.stringify({ bot: BOT_ID, folder: activeFolder, filename: file.name, mime, size: file.size, s3_key: presign.key }), + }); + fill.style.width = "100%"; stat.textContent = "✓"; stat.style.color = "var(--success)"; + setTimeout(() => row.remove(), 1500); + toast(`${file.name} hochgeladen`, "success"); + await loadDocs(); + // Re-load klausuren after a delay (auto-parse may take a few seconds) + if (activeFolder === "curriculum") setTimeout(() => loadKlausuren(), 6000); + } catch (e) { + console.error(e); + stat.textContent = "✗"; stat.style.color = "var(--danger)"; + toast(`Upload fehlgeschlagen: ${e.message}`, "error"); + } + } + + async function toggleDoc(id) { + const d = docs.find(x => x.id === id); + if (!d) return; + d.enabled = !d.enabled; + renderDocList(); + try { + const r = await api(`/api/tutor/documents/${id}/toggle`, { method: "POST" }); + d.enabled = r.enabled; + renderDocList(); renderStatus(); + } catch { d.enabled = !d.enabled; renderDocList(); toast("Toggle fehlgeschlagen", "error"); } + } + async function deleteDoc(id, name) { + if (!confirm(`„${name}" wirklich löschen? Die Datei wird auch im Bunker entfernt.`)) return; + try { await api(`/api/tutor/documents/${id}`, { method: "DELETE" }); toast("Datei gelöscht", "success"); await loadDocs(); } + catch (e) { toast(`Löschen fehlgeschlagen: ${e.message}`, "error"); } + } + + // ─── Render: Documents space ─────────────────────────────────── + function renderCounts() { + const counts = { curriculum: 0, official: 0, own: 0, role: 0 }; + for (const d of docs) counts[d.folder] = (counts[d.folder] || 0) + 1; + for (const [k, v] of Object.entries(counts)) { + const el = document.querySelector(`.folder-count[data-count="${k}"]`); + if (el) el.textContent = v; + } + } + function renderDocList() { + const list = $("#doc-list"); + const empty = $("#doc-empty"); + const filtered = docs.filter(d => d.folder === activeFolder).sort((a, b) => (b.created || "").localeCompare(a.created || "")); + list.innerHTML = ""; + if (!filtered.length) { empty.hidden = false; return; } + empty.hidden = true; + for (const d of filtered) { + const li = document.createElement("li"); + li.className = `doc-row ${d.enabled ? "" : "is-disabled"}`; + const ext = d.has_text ? "✓ indexiert" : "⌛ indexiere…"; + const sizeKb = d.file_size ? `· ${(d.file_size/1024).toFixed(0)} KB ` : ""; + li.innerHTML = ` +
    +

    ${escapeHtml(d.filename)}

    +

    ${ext} ${sizeKb}· ${relativeTime(d.created)}

    +
    +
    + + +
    `; + list.appendChild(li); + } + list.querySelectorAll(".toggle").forEach(el => el.addEventListener("click", () => toggleDoc(el.dataset.id))); + list.querySelectorAll("[data-del]").forEach(el => el.addEventListener("click", () => deleteDoc(el.dataset.del, el.dataset.name))); + } + function renderStatus() { + const enabled = docs.filter(d => d.enabled); + const byFolder = enabled.reduce((acc, d) => { acc[d.folder] = (acc[d.folder] || 0) + 1; return acc; }, {}); + const summary = $("#status-summary"); + if (!enabled.length && !mastery.length) { + summary.innerHTML = `Noch keine Quellen aktiv. Lade ein Klausurplan-Dokument hoch oder starte eine Diagnose im „Lernen"-Tab.`; + return; + } + const parts = []; + if (byFolder.curriculum) parts.push(`${byFolder.curriculum} Curriculum`); + if (byFolder.official) parts.push(`${byFolder.official} offizielle`); + if (byFolder.own) parts.push(`${byFolder.own} eigene`); + if (byFolder.role) parts.push(`${byFolder.role} Schwerpunkt`); + if (mastery.length) parts.push(`${mastery.length} Themen mit Lernstand`); + summary.innerHTML = `Luna nutzt: ${parts.join(" · ")}. Im „Lernen"-Tab kannst du den Stand visualisieren.`; + } + function setActiveFolder(folder) { + activeFolder = folder; + $$(".folder-tab").forEach(t => t.setAttribute("aria-selected", t.dataset.folder === folder ? "true" : "false")); + $("#folder-help").innerHTML = FOLDER_HELP[folder] || ""; + renderDocList(); + } + + // ─── Render: Klausur cards ───────────────────────────────────── + function renderKlausuren() { + const list = $("#klausur-list"); + const empty = $("#klausur-empty"); + list.innerHTML = ""; + if (!klausuren.length) { list.appendChild(empty); empty.hidden = false; return; } + if (empty) empty.hidden = true; + for (const k of klausuren) { + const card = document.createElement("div"); + card.className = "klausur-card"; + const topicsHtml = (Array.isArray(k.topics) ? k.topics : []).slice(0, 8).map((t, i) => + `${escapeHtml(t)}`).join(""); + const notesHtml = k.notes ? `
    ${escapeHtml(k.notes)}
    ` : ""; + card.innerHTML = ` +
    + ${escapeHtml(String(k.order_index ?? "?"))} + ${escapeHtml(k.name)} +
    +
    ${topicsHtml}
    + ${notesHtml} +
    ▶ Mit Luna durchgehen
    +
    + + + +
    `; + // main click → chat-prep + card.addEventListener("click", (ev) => { + if (ev.target.closest(".klausur-card-mini") || ev.target.closest(".klausur-card-topic")) return; + const prompt = `Bereite mich auf "${k.name}" vor. Mach einen knappen Lernplan über die wichtigsten Themen, dann starten wir mit dem ersten — verstanden?`; + openDock(prompt, true); + }); + // topic-pill click → open minigame for that topic + card.querySelectorAll(".klausur-card-topic").forEach(el => { + el.addEventListener("click", (ev) => { ev.stopPropagation(); openMinigameLauncher(el.dataset.topic, el.dataset.klausur); }); + }); + // mini action buttons + card.querySelectorAll(".klausur-card-mini").forEach(btn => { + btn.addEventListener("click", (ev) => { + ev.stopPropagation(); + const game = btn.dataset.act; + const topicLabel = btn.dataset.name; + launchMinigame(game, { topic_key: topicKeyFromLabel(topicLabel), topic_label: topicLabel }); + }); + }); + list.appendChild(card); + } + } + function topicKeyFromLabel(label) { + return String(label).toLowerCase().replace(/[^a-z0-9äöüß]+/g, "-").replace(/^-|-$/g, ""); + } + + // ─── Render: Heatmap + Topic-Picker ──────────────────────────── + function renderHeatmap() { + const grid = $("#heatmap-grid"); + if (!mastery.length) { + grid.innerHTML = `
    Noch keine Daten. Wähle unten ein Thema und mache einen Kompetenz-Check.
    `; + return; + } + grid.innerHTML = ""; + // Sort by recently-seen + const sorted = [...mastery].sort((a, b) => (b.last_seen_at || "").localeCompare(a.last_seen_at || "")); + for (const m of sorted) { + const cell = document.createElement("div"); + cell.className = "heatmap-cell"; + cell.dataset.level = m.level; + const accuracy = m.attempts ? Math.round((m.correct / m.attempts) * 100) : null; + const accStr = accuracy === null ? "" : `${accuracy}% richtig`; + cell.innerHTML = ` +
    ${escapeHtml(m.topic_label || m.topic_key)}
    +
    + Level ${m.level}/5 + ${accStr} +
    +
    `; + cell.addEventListener("click", () => openMinigameLauncher(m.topic_key, m.topic_label || m.topic_key, m)); + grid.appendChild(cell); + } + } + + function topicGridSource() { + const sel = $("#topic-source").value; + const data = []; + if (sel === "klausur") { + // unique topics from klausuren list + const seen = new Set(); + for (const k of klausuren) { + for (const t of (k.topics || [])) { + const key = topicKeyFromLabel(t); + if (seen.has(key)) continue; + seen.add(key); + data.push({ topic_key: key, topic_label: t, group: k.name }); + } + } + if (!data.length) data.push({ empty: true, msg: "Noch keine Klausuren erkannt — lade einen Klausurplan im Curriculum-Ordner hoch." }); + } else { + // From curricula.json + const cur = curricula?.curricula?.find(c => c.id === sel); + if (!cur) { data.push({ empty: true, msg: "Curriculum nicht geladen." }); } + else { + for (const mod of cur.modules || []) { + data.push({ topic_key: `${cur.id}/${mod.id}`, topic_label: mod.title, group: cur.short || cur.title, curriculum_id: cur.id, module_id: mod.id }); + } + } + } + return data; + } + function renderTopicGrid() { + const grid = $("#topic-grid"); + const items = topicGridSource(); + grid.innerHTML = ""; + if (items[0]?.empty) { + const div = document.createElement("div"); div.className = "empty-state"; div.textContent = items[0].msg; + grid.appendChild(div); return; + } + const lookup = new Map(mastery.map(m => [m.topic_key, m])); + for (const t of items) { + const m = lookup.get(t.topic_key); + const stars = m ? "★".repeat(m.level) + "☆".repeat(5 - m.level) : "☆☆☆☆☆"; + const meta = m ? `Level ${m.level}/5 · ${m.attempts || 0} Versuche` : (t.group || "—"); + const pill = document.createElement("button"); + pill.className = "topic-pill"; + if (m && m.level >= 4) pill.dataset.mastered = "true"; + pill.innerHTML = ` + ${escapeHtml(t.topic_label)} + ${escapeHtml(meta)} + ${stars}`; + pill.addEventListener("click", () => openMinigameLauncher(t.topic_key, t.topic_label, t)); + grid.appendChild(pill); + } + } + + // ─── Minigame launcher ───────────────────────────────────────── + let pendingTopic = null; + function openMinigameLauncher(topicKey, topicLabel, ctx = {}) { + pendingTopic = { + topic_key: topicKey, + topic_label: topicLabel, + curriculum_id: ctx.curriculum_id || (typeof topicKey === "string" && topicKey.includes("/") ? topicKey.split("/")[0] : ""), + module_id: ctx.module_id || (typeof topicKey === "string" && topicKey.includes("/") ? topicKey.split("/")[1] : ""), + }; + $("#launcher-topic").textContent = topicLabel; + $("#minigame-launcher").hidden = false; + $("#minigame-launcher").scrollIntoView({ behavior: "smooth", block: "center" }); + setActiveSpace("lernen"); + } + $("#launcher-close").addEventListener("click", () => { $("#minigame-launcher").hidden = true; pendingTopic = null; }); + $$(".minigame-card").forEach(c => c.addEventListener("click", () => { + if (!pendingTopic) { toast("Bitte erst ein Thema wählen", "error"); return; } + launchMinigame(c.dataset.game, pendingTopic); + })); + + // ─── Minigame runner ──────────────────────────────────────────── + async function launchMinigame(game, topic) { + activeMinigame = { game, topic, state: {} }; + const stage = $("#minigame-stage"); + stage.hidden = false; + $("#minigame-launcher").hidden = true; + stage.innerHTML = `
    Luna bereitet das Spiel vor…
    `; + stage.scrollIntoView({ behavior: "smooth", block: "start" }); + try { + if (game === "diagnose") await runDiagnose(topic); + else if (game === "klinikfall") await runKlinikfall(topic); + else if (game === "stimmt-das") await runStimmtDas(topic); + else if (game === "quiz-classic") await runQuizClassic(topic); + else if (game === "flashcards") await runFlashcards(topic); + else if (game === "explain") await runExplain(topic); + } catch (e) { + stage.innerHTML = `
    Fehler: ${escapeHtml(e.message || String(e))}
    `; + } + } + function endMinigame() { + activeMinigame = null; + $("#minigame-stage").hidden = true; + $("#minigame-stage").innerHTML = ""; + loadMastery(); // refresh heatmap + } + + // Diagnose: 5 MCQ ─────────────────────────────────────────────── + async function runDiagnose(topic) { + const data = await api("/api/tutor/minigame/diagnose", { method: "POST", body: JSON.stringify({ bot: BOT_ID, topic: topic.topic_key, topic_label: topic.topic_label, curriculum_id: topic.curriculum_id, module_id: topic.module_id }) }); + activeMinigame.state = { questions: data.questions, idx: 0, correct: 0, total: data.questions.length, answers: [] }; + renderDiagnoseQuestion(); + } + function renderDiagnoseQuestion() { + const s = activeMinigame.state; + const q = s.questions[s.idx]; + const stage = $("#minigame-stage"); + const progress = ((s.idx) / s.total) * 100; + stage.innerHTML = ` +
    +

    🩺 Kompetenz-Check: ${escapeHtml(activeMinigame.topic.topic_label)}

    + Frage ${s.idx + 1} / ${s.total} +
    +
    +
    +

    ${escapeHtml(q.q)}

    +
    ${q.options.map((opt, i) => ` + `).join("")} +
    + + +
    `; + stage.querySelectorAll(".qa-option").forEach(btn => btn.addEventListener("click", () => answerDiagnose(parseInt(btn.dataset.i, 10)))); + $("#qa-next").addEventListener("click", () => { + s.idx++; + if (s.idx >= s.total) finishDiagnose(); + else renderDiagnoseQuestion(); + }); + } + function answerDiagnose(picked) { + const s = activeMinigame.state; + const q = s.questions[s.idx]; + const correct = picked === q.correct; + if (correct) s.correct++; + s.answers.push({ q: q.q, picked, correct: q.correct, was_right: correct }); + const stage = $("#minigame-stage"); + stage.querySelectorAll(".qa-option").forEach((btn, i) => { + btn.disabled = true; + if (i === q.correct) btn.dataset.state = "correct"; + else if (i === picked) btn.dataset.state = "wrong"; + else btn.dataset.state = "reveal-correct"; + }); + const fb = $("#qa-feedback"); + fb.className = `qa-feedback ${correct ? "correct" : "wrong"}`; + fb.innerHTML = `${correct ? "✓ Richtig" : "✗ Falsch"}. ${renderMD(q.explain || "")}`; + fb.hidden = false; + $("#qa-actions").hidden = false; + } + async function finishDiagnose() { + const s = activeMinigame.state; + await scoreMinigame(s.correct, s.total); + const ratio = s.correct / s.total; + const level = ratio >= 0.95 ? 5 : ratio >= 0.85 ? 4 : ratio >= 0.7 ? 3 : ratio >= 0.5 ? 2 : ratio >= 0.3 ? 1 : 0; + const stars = "★".repeat(level) + "☆".repeat(5 - level); + const verdict = level >= 4 ? "Stark! Du hast das Thema im Griff." : level >= 3 ? "Solide. Ein paar Lücken, gut zum Üben." : level >= 2 ? "Grundlagen vorhanden, Vertiefung nötig." : "Hier lohnt sich gezieltes Üben — mach z.B. einen Klinikfall oder lass dir's erklären."; + const stage = $("#minigame-stage"); + stage.innerHTML = ` +
    +
    ${stars}
    +
    ${s.correct} von ${s.total} richtig — Level ${level}/5
    +
    ${escapeHtml(verdict)}
    +
    + + + +
    +
    `; + $("#sum-explain").addEventListener("click", () => { + const wrongs = s.answers.filter(a => !a.was_right).map(a => a.q).join("\n- "); + openDock(`Wir haben gerade "${activeMinigame.topic.topic_label}" geübt. Diese Fragen waren noch unsicher:\n- ${wrongs}\n\nKannst du mir die Lücken nochmal sokratisch erklären?`, true); + endMinigame(); + }); + $("#sum-klinik").addEventListener("click", () => launchMinigame("klinikfall", activeMinigame.topic)); + $("#sum-close").addEventListener("click", endMinigame); + } + + // Klinikfall ──────────────────────────────────────────────────── + async function runKlinikfall(topic) { + const data = await api("/api/tutor/minigame/klinikfall", { method: "POST", body: JSON.stringify({ bot: BOT_ID, topic: topic.topic_key, topic_label: topic.topic_label }) }); + activeMinigame.state = { case: data, idx: 0, correct: 0, total: data.stages.length, picks: [] }; + renderKlinikIntro(); + } + function renderKlinikIntro() { + const c = activeMinigame.state.case; + const stage = $("#minigame-stage"); + stage.innerHTML = ` +
    +

    🏥 ${escapeHtml(c.title)}

    + Klinikfall +
    +
    ${renderMD(c.patient || "")}
    +
    `; + $("#fall-start").addEventListener("click", () => renderKlinikStage()); + } + function renderKlinikStage() { + const s = activeMinigame.state; + const stg = s.case.stages[s.idx]; + const stage = $("#minigame-stage"); + const progress = ((s.idx) / s.total) * 100; + stage.innerHTML = ` +
    +

    🏥 ${escapeHtml(s.case.title)}

    + Stage ${s.idx + 1} / ${s.total} +
    +
    +
    +
    Entscheidungspunkt ${s.idx + 1}
    +

    ${escapeHtml(stg.prompt)}

    +
    ${stg.options.map((opt, i) => ` + `).join("")} +
    + + +
    `; + stage.querySelectorAll(".qa-option").forEach(btn => btn.addEventListener("click", () => { + const i = parseInt(btn.dataset.i, 10); + const opt = stg.options[i]; + const wasRight = !!opt.correct; + if (wasRight) s.correct++; + s.picks.push({ stage: s.idx, picked: i, was_right: wasRight }); + stage.querySelectorAll(".qa-option").forEach((b, j) => { + b.disabled = true; + const oj = stg.options[j]; + if (oj.correct) b.dataset.state = "correct"; + else if (j === i) b.dataset.state = "wrong"; + }); + const fb = $("#qa-feedback"); + fb.className = `qa-feedback ${wasRight ? "correct" : "wrong"}`; + fb.innerHTML = `${wasRight ? "✓ Stimmt" : "✗ Nicht ideal"}. ${renderMD(opt.reason || "")}`; + if (stg.learning_point) fb.innerHTML += `
    Take-away: ${escapeHtml(stg.learning_point)}
    `; + fb.hidden = false; + $("#qa-actions").hidden = false; + $("#fall-next").addEventListener("click", () => { + s.idx++; + if (s.idx >= s.total) finishKlinik(); + else renderKlinikStage(); + }); + })); + } + async function finishKlinik() { + const s = activeMinigame.state; + await scoreMinigame(s.correct, s.total); + const stage = $("#minigame-stage"); + stage.innerHTML = ` +
    +
    ${s.correct} von ${s.total} Entscheidungen passten
    +
    ${escapeHtml(s.case.synthesis || "Klinikfall abgeschlossen.")}
    +
    + + +
    +
    `; + $("#sum-discuss").addEventListener("click", () => { + openDock(`Ich hab gerade einen Klinikfall zum Thema "${activeMinigame.topic.topic_label}" gemacht (${s.correct}/${s.total}). Was sind die wichtigsten Take-aways die ich mir merken sollte?`, true); + endMinigame(); + }); + $("#sum-close").addEventListener("click", endMinigame); + } + + // Stimmt-das? ─────────────────────────────────────────────────── + async function runStimmtDas(topic) { + const data = await api("/api/tutor/minigame/stimmt-das", { method: "POST", body: JSON.stringify({ bot: BOT_ID, topic: topic.topic_key, topic_label: topic.topic_label }) }); + activeMinigame.state = { items: data.items, picks: [], correct: 0, total: data.items.length }; + renderStimmtDas(); + } + function renderStimmtDas() { + const s = activeMinigame.state; + const stage = $("#minigame-stage"); + stage.innerHTML = ` +
    +

    🤔 Stimmt das? — ${escapeHtml(activeMinigame.topic.topic_label)}

    + 0 / ${s.total} beantwortet +
    +
    + `; + const grid = $("#stimmt-grid"); + s.items.forEach((it, idx) => { + const card = document.createElement("div"); + card.className = "stimmt-card"; + card.innerHTML = ` +
    ${escapeHtml(it.statement)}
    +
    + + +
    + `; + grid.appendChild(card); + }); + grid.querySelectorAll(".stimmt-btn").forEach(btn => btn.addEventListener("click", () => { + const idx = parseInt(btn.dataset.idx, 10); + const pickedTrue = btn.dataset.pick === "true"; + if (s.picks[idx] !== undefined) return; + s.picks[idx] = pickedTrue; + const correct = pickedTrue === !!s.items[idx].is_true; + if (correct) s.correct++; + // Disable both buttons; mark + const card = btn.closest(".stimmt-card"); + card.querySelectorAll(".stimmt-btn").forEach(b => { + b.disabled = true; + const isPick = b.dataset.pick === btn.dataset.pick; + if (isPick) b.dataset.state = correct ? "correct" : "wrong"; + else if (b.dataset.pick === String(!!s.items[idx].is_true)) b.dataset.state = "correct"; + }); + const ex = $(`#sd-ex-${idx}`); + ex.className = `stimmt-explain ${correct ? "correct" : "wrong"}`; + const trapTag = s.items[idx].trap_type && !s.items[idx].is_true ? `${escapeHtml(s.items[idx].trap_type)}` : ""; + ex.innerHTML = `${trapTag}${escapeHtml(s.items[idx].explanation || "")}`; + ex.hidden = false; + $("#sd-progress").textContent = `${s.picks.filter(p => p !== undefined).length} / ${s.total} beantwortet`; + if (s.picks.filter(p => p !== undefined).length === s.total) $("#sd-actions").hidden = false; + })); + $("#sd-finish").addEventListener("click", finishStimmtDas); + } + async function finishStimmtDas() { + const s = activeMinigame.state; + await scoreMinigame(s.correct, s.total); + const ratio = s.correct / s.total; + const stage = $("#minigame-stage"); + stage.innerHTML = ` +
    +
    ${s.correct} von ${s.total} richtig erkannt — ${Math.round(ratio*100)}%
    +
    ${ratio >= 0.8 ? "Du hast die Misconceptions gut gefiltert." : "Mehrere Fallen sind durchgerutscht — guter Hinweis, wo's noch wackelt."}
    +
    + + +
    +
    `; + $("#sum-deepen").addEventListener("click", () => { + const wrongs = s.items.filter((it, i) => s.picks[i] !== !!it.is_true).map(it => it.statement).slice(0, 3).join("\n- "); + openDock(`Beim "Stimmt-das?"-Spiel zu "${activeMinigame.topic.topic_label}" bin ich hier auf Fallen reingefallen:\n- ${wrongs}\n\nLass uns die nochmal sauber durchgehen.`, true); + endMinigame(); + }); + $("#sum-close").addEventListener("click", endMinigame); + } + + // Quiz-classic / Flashcards / Explain — via chat-dock w/ existing engine + async function runQuizClassic(topic) { + endMinigame(); + openDock(`QUIZ_REQUEST topic="${topic.topic_label}" count=10`, true); + } + async function runFlashcards(topic) { + endMinigame(); + openDock(`FLASHCARD_REQUEST topic="${topic.topic_label}" count=8`, true); + } + async function runExplain(topic) { + endMinigame(); + openDock(`Ich erkläre dir jetzt das Thema "${topic.topic_label}" in meinen eigenen Worten — und du gibst mir Feedback zu Klarheit, Vollständigkeit und ob ich Verständnislücken habe. Bist du bereit? Wenn ja, schreib bitte „Los — fang an" und ich erkläre los.`, true); + } + + async function scoreMinigame(correct, total) { + if (!activeMinigame) return; + try { + await api("/api/tutor/minigame/score", { method: "POST", body: JSON.stringify({ + bot: BOT_ID, + topic_key: activeMinigame.topic.topic_key, + topic_label: activeMinigame.topic.topic_label, + curriculum_id: activeMinigame.topic.curriculum_id || "", + module_id: activeMinigame.topic.module_id || "", + correct, total, + }) }); + } catch (e) { console.warn("score", e); } + } + + // ─── Drag & drop / file picker ──────────────────────────────── + function onFiles(files) { if (!files || !files.length) return; for (const f of files) uploadFile(f); } + $("#upload-zone").addEventListener("click", () => $("#file-input").click()); + $("#upload-zone").addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); $("#file-input").click(); }}); + $("#file-input").addEventListener("change", (e) => { onFiles(e.target.files); e.target.value = ""; }); + $("#upload-zone").addEventListener("dragover", (e) => { e.preventDefault(); $("#upload-zone").dataset.dragover = "true"; }); + $("#upload-zone").addEventListener("dragleave", () => { $("#upload-zone").dataset.dragover = "false"; }); + $("#upload-zone").addEventListener("drop", (e) => { e.preventDefault(); $("#upload-zone").dataset.dragover = "false"; onFiles(e.dataTransfer.files); }); + + // Folder + Space-Tabs + $$(".folder-tab").forEach(t => t.addEventListener("click", () => setActiveFolder(t.dataset.folder))); + $$(".space-tab").forEach(t => t.addEventListener("click", () => setActiveSpace(t.dataset.space))); + function setActiveSpace(space) { + activeSpace = space; + $$(".space-tab").forEach(t => t.setAttribute("aria-selected", t.dataset.space === space ? "true" : "false")); + $$(".space[data-space]").forEach(s => s.dataset.active = s.dataset.space === space ? "true" : "false"); + if (space === "lernen") { renderHeatmap(); renderTopicGrid(); } + if (space === "klausuren") renderKlausuren(); + if (space === "steuern") { loadPersona(); refreshTgStatus(); } + } + $("#topic-source").addEventListener("change", renderTopicGrid); + + // ─── Telegram pairing ───────────────────────────────────────── + async function refreshTgStatus() { + try { + const data = await api(`/api/tutor/telegram/status?bot=${BOT_ID}`); + const badge = $("#tg-status-badge"); + const unlinkBtn = $("#tg-unlink"); + if (data.linked) { + badge.textContent = `✓ verbunden ${data.chat_id_preview || ""}`; + badge.className = "tg-badge linked"; + unlinkBtn.hidden = false; + } else { + badge.textContent = "noch nicht verbunden"; + badge.className = "tg-badge unlinked"; + unlinkBtn.hidden = true; + } + } catch (e) { /* */ } + } + $("#tg-generate").addEventListener("click", async () => { + try { + const data = await api("/api/tutor/telegram/link-code", { method: "POST", body: JSON.stringify({ bot: BOT_ID }) }); + $("#tg-code-text").textContent = `/link ${data.code}`; + $("#tg-code-display").hidden = false; + const ttlMin = Math.ceil((data.expires_at - Date.now()) / 60000); + $("#tg-code-ttl").textContent = String(ttlMin); + // periodic poll: when linked, show success + const interval = setInterval(async () => { + const s = await api(`/api/tutor/telegram/status?bot=${BOT_ID}`).catch(() => null); + if (s?.linked) { + clearInterval(interval); + $("#tg-code-display").hidden = true; + toast("✓ Telegram verbunden!", "success"); + refreshTgStatus(); + } + }, 5000); + // Stop polling after TTL + setTimeout(() => clearInterval(interval), data.expires_at - Date.now() + 5000); + } catch (e) { toast(`Fehler: ${e.message}`, "error"); } + }); + $("#tg-unlink").addEventListener("click", async () => { + if (!confirm("Telegram-Verbindung wirklich lösen?")) return; + try { + await api("/api/tutor/telegram/link", { method: "DELETE", body: JSON.stringify({ bot: BOT_ID }) }); + toast("Verbindung gelöst", "success"); + refreshTgStatus(); + } catch (e) { toast(`Fehler: ${e.message}`, "error"); } + }); + + // Persona form + $("#persona-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const overrides = $("#persona-overrides").value; + $("#persona-status").textContent = "speichere…"; + try { + await api("/api/tutor/persona", { method: "POST", body: JSON.stringify({ bot: BOT_ID, overrides }) }); + const status = $("#persona-status"); + status.textContent = "✓ gespeichert"; + status.className = "persona-status saved"; + setTimeout(() => { status.textContent = "—"; status.className = "persona-status"; }, 3000); + toast("Steuerung gespeichert. Luna sieht das ab dem nächsten Chat.", "success"); + } catch (err) { + $("#persona-status").textContent = "Fehler"; + toast(`Speichern fehlgeschlagen: ${err.message}`, "error"); + } + }); + + // ─── Chat dock — resizable, MD, structured-output ────────────── + const dock = $("#chat-dock"); + const dockBox = $("#dock-box"); + const dockForm = $("#dock-form"); + const dockInput = $("#dock-input"); + + // restore saved size + try { + const sz = JSON.parse(localStorage.getItem(LS_DOCK_SIZE) || "null"); + if (sz && sz.w && sz.h) { dock.style.width = sz.w + "px"; dock.style.height = sz.h + "px"; } + } catch { /* */ } + + // Resize via top-left corner drag + const resizer = $("#dock-resize"); + let startX = 0, startY = 0, startW = 0, startH = 0, resizing = false; + resizer.addEventListener("mousedown", (e) => { + e.preventDefault(); + resizing = true; + startX = e.clientX; startY = e.clientY; + startW = dock.offsetWidth; startH = dock.offsetHeight; + document.body.style.userSelect = "none"; + }); + window.addEventListener("mousemove", (e) => { + if (!resizing) return; + const dx = startX - e.clientX, dy = startY - e.clientY; + const newW = Math.max(340, Math.min(window.innerWidth - 32, startW + dx)); + const newH = Math.max(420, Math.min(window.innerHeight - 32, startH + dy)); + dock.style.width = newW + "px"; + dock.style.height = newH + "px"; + }); + window.addEventListener("mouseup", () => { + if (!resizing) return; + resizing = false; + document.body.style.userSelect = ""; + localStorage.setItem(LS_DOCK_SIZE, JSON.stringify({ w: dock.offsetWidth, h: dock.offsetHeight })); + }); + + function openDock(prefill, autosend) { + dock.dataset.open = "true"; $("#dock-open").hidden = true; + renderChat(); + if (prefill) dockInput.value = prefill; + if (autosend) dockForm.requestSubmit(); + setTimeout(() => dockInput.focus(), 100); + } + $("#dock-open").addEventListener("click", () => openDock("", false)); + $("#dock-collapse").addEventListener("click", () => { dock.dataset.open = "false"; $("#dock-open").hidden = false; }); + $("#dock-reset").addEventListener("click", () => { if (confirm("Chat-Verlauf löschen?")) { chatHistory = []; persistChat(); renderChat(); }}); + + function renderChat() { + dockBox.innerHTML = ""; + for (const m of chatHistory) { + const el = document.createElement("div"); + el.className = `dock-msg ${m.role}`; + if (m.role === "assistant") { + if (m.typing) { el.classList.add("typing"); el.textContent = "…"; } + else { + // Try to parse for structured output (quiz/flashcards/case) + const struct = tryParseStruct(m.content); + if (struct) { + el.innerHTML = `
    ${renderMD(stripJsonBlock(m.content))}
    `; + el.appendChild(renderStructInline(struct)); + } else { + el.innerHTML = `
    ${renderMD(m.content)}
    `; + } + if (m.sources && m.sources.length) { + const src = document.createElement("div"); + src.className = "sources"; + src.innerHTML = "📚 Verwendete Quellen: " + m.sources.map(s => + `${escapeHtml(s.folder)}/${escapeHtml(s.filename)}`).join(""); + el.appendChild(src); + } + } + } else { + el.textContent = m.content; + } + dockBox.appendChild(el); + } + dockBox.scrollTop = dockBox.scrollHeight; + } + function tryParseStruct(text) { + if (!text) return null; + const m = text.trim().match(/^(\{[\s\S]*\})\s*$/) || text.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/); + if (!m) return null; + try { + const obj = JSON.parse(m[1]); + if (obj.type === "quiz" && Array.isArray(obj.questions)) return obj; + if (obj.type === "flashcards" && Array.isArray(obj.cards)) return obj; + } catch { return null; } + return null; + } + function stripJsonBlock(text) { + return text.replace(/```(?:json)?\s*\{[\s\S]*?\}\s*```/g, "").replace(/^\{[\s\S]*\}\s*$/, "").trim(); + } + function renderStructInline(obj) { + const w = document.createElement("div"); + w.className = "dock-struct"; + if (obj.type === "quiz") { + w.innerHTML = `
    📝 Quiz · ${escapeHtml(obj.topic || "")}
    `; + let idx = 0, correct = 0; + const total = obj.questions.length; + const renderQ = () => { + const q = obj.questions[idx]; + w.innerHTML = `
    📝 Quiz ${idx+1} / ${total}
    +
    ${escapeHtml(q.q)}
    +
    ${q.options.map((opt, i) => ``).join("")}
    + + `; + w.querySelectorAll(".ds-option").forEach(btn => btn.addEventListener("click", () => { + const i = parseInt(btn.dataset.i, 10); + const c = i === q.correct; + if (c) correct++; + w.querySelectorAll(".ds-option").forEach((b, j) => { + b.disabled = true; + if (j === q.correct) b.dataset.state = "correct"; + else if (j === i) b.dataset.state = "wrong"; + }); + const fb = w.querySelector("#ds-fb"); + fb.className = `qa-feedback ${c?"correct":"wrong"}`; + fb.innerHTML = `${c?"✓":"✗"} ${escapeHtml(q.explain || "")}`; + fb.hidden = false; + w.querySelector("#ds-actions").hidden = false; + w.querySelector("#ds-next").addEventListener("click", () => { + idx++; + if (idx >= total) { w.innerHTML = `
    ✅ Quiz fertig
    ${correct}/${total} richtig.
    `; } + else renderQ(); + }); + })); + }; + renderQ(); + } else if (obj.type === "flashcards") { + w.innerHTML = `
    🃏 Karteikarten · ${escapeHtml(obj.topic || "")}
    `; + let idx = 0; + const total = obj.cards.length; + const renderC = () => { + const c = obj.cards[idx]; + w.innerHTML = `
    🃏 Karte ${idx+1} / ${total}
    +
    ${escapeHtml(c.front)}
    +
    `; + w.querySelector("#ds-flip").addEventListener("click", () => { + w.innerHTML = `
    🃏 Karte ${idx+1} / ${total}
    +
    F: ${escapeHtml(c.front)}

    A: ${escapeHtml(c.back)}${c.hint ? `
    Hinweis: ${escapeHtml(c.hint)}` : ""}
    +
    + + + +
    `; + w.querySelectorAll("[data-r]").forEach(b => b.addEventListener("click", () => { idx++; if (idx >= total) w.innerHTML = `
    ✅ Stapel durch
    `; else renderC(); })); + }); + }; + renderC(); + } + return w; + } + + async function sendChat(text) { + chatHistory.push({ role: "user", content: text }); + persistChat(); renderChat(); + const tmp = { role: "assistant", typing: true }; + chatHistory.push(tmp); renderChat(); + try { + const headers = { "Content-Type": "application/json", Authorization: `Bearer ${BOT_KEY}` }; + // Pass user-token so backend can inject mastery + persona blocks + if (token) headers["X-User-Token"] = token; + const r = await fetch(`https://llm.qognio.com/api/bots/${SLUG}/chat`, { + method: "POST", + headers, + body: JSON.stringify({ + message: text, + history: chatHistory.filter(m => m !== tmp).slice(-10).map(m => ({ role: m.role, content: m.content })), + }), + }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const data = await r.json(); + const idx = chatHistory.indexOf(tmp); + chatHistory[idx] = { role: "assistant", content: data.reply || "(leere Antwort)", sources: data.tutor_sources }; + } catch (e) { + const idx = chatHistory.indexOf(tmp); + chatHistory[idx] = { role: "assistant", content: `Fehler: ${e.message}` }; + } + persistChat(); renderChat(); + } + function persistChat() { localStorage.setItem(LS_CHAT, JSON.stringify(chatHistory.slice(-30))); } + + dockForm.addEventListener("submit", (e) => { + e.preventDefault(); + const text = dockInput.value.trim(); + if (!text) return; + dockInput.value = ""; + dockInput.style.height = "auto"; + sendChat(text); + }); + dockInput.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); dockForm.requestSubmit(); }}); + dockInput.addEventListener("input", () => { dockInput.style.height = "auto"; dockInput.style.height = Math.min(140, dockInput.scrollHeight) + "px"; }); + + // ─── Helpers ─────────────────────────────────────────────────── + function relativeTime(iso) { + if (!iso) return ""; + const d = new Date(iso); + const sec = (Date.now() - d.getTime()) / 1000; + if (sec < 60) return "gerade eben"; + if (sec < 3600) return `vor ${Math.floor(sec/60)} min`; + if (sec < 86400) return `vor ${Math.floor(sec/3600)} h`; + return d.toLocaleDateString("de-DE", { day: "2-digit", month: "short" }); + } + function guessMime(name) { + const ext = name.toLowerCase().split(".").pop(); + return ({ pdf:"application/pdf", txt:"text/plain", md:"text/markdown", csv:"text/csv", json:"application/json", + png:"image/png", jpg:"image/jpeg", jpeg:"image/jpeg", webp:"image/webp" })[ext] || "application/octet-stream"; + } + + async function refresh() { + setActiveFolder(activeFolder); + setActiveSpace(activeSpace); + try { + await loadCurricula(); + await loadDocs(); + await loadKlausuren(); + await loadMastery(); + } catch (e) { console.warn(e); toast("Lade-Fehler — bist du eingeloggt?", "error"); } + } + + // Boot + if (token && user) { showApp(); refresh(); } + else showLogin(); +})(); diff --git a/src/cockpit-overlay/index.html b/src/cockpit-overlay/index.html new file mode 100644 index 0000000..b6f37d2 --- /dev/null +++ b/src/cockpit-overlay/index.html @@ -0,0 +1,290 @@ + + + + + Luna · Tutor-Cockpit + + + + + + + +
    + +
    +
    + +
    + Luna Cockpit + Eigene Unterlagen · Klausur-Plan · Kompetenz-Diagnose +
    +
    +
    +
    + + +
    +
    + + + + +
    + +
    +

    Was Luna gerade weiß

    +
    Lade…
    +
    + + + + + +
    + +
    +
    +
    + +
    + ⬆️ + Datei hier ablegen oder klicken + PDF, Markdown, Text — max 20 MB +
    + +
    +
      + +
      +
      + + +
      +
      +

      📋 Dein Klausur-Plan

      +

      Aus deinem Curriculum extrahiert. Klick auf eine Klausur, um Luna gezielt darauf vorzubereiten.

      +
      +
      +
      Noch keine Klausuren erkannt. Lade ein Klausurplan-Dokument im Curriculum-Ordner hoch.
      +
      +
      + + +
      +
      +

      🧠 Lernen & Üben

      +

      Wähle ein Thema aus deinem Klausur-Plan oder Curriculum, dann starte ein Mini-Spiel.

      +
      + + +
      +
      +

      Dein aktueller Stand

      +
      + 0 noch nicht + 2 in Arbeit + 4 stark + 5 gemeistert +
      +
      +
      +
      Klick auf ein Thema unten, um deinen Stand erstmals zu erfassen.
      +
      +
      + + +
      +
      +

      Themen

      + +
      +
      +
      + + + + + + +
      + + +
      +
      +

      🎛 Bot steuern

      +

      Schreibe hier rein, wie Luna mit dir arbeiten soll. Beispiele:

      +
        +
      • „Antworte ausführlicher und gib mehr klinische Beispiele"
      • +
      • „Stell mir öfter sokratische Rückfragen statt direkt zu erklären"
      • +
      • „Ich lerne lieber visuell — beschreib Diagramme detailliert"
      • +
      • „Fokus auf Klausur 1 — ignoriere andere Themen wenn ich nicht explizit frage"
      • +
      +
      +
      + +
      + + +
      +
      + + +
      +
      +

      📱 Mobil mit Telegram

      + prüfe… +
      +

      + Verbinde diesen Cockpit-Account mit dem + @qognioLunaBot + auf Telegram. Dann kannst du Luna unterwegs fragen — und sie nutzt deinen Lernstand & Dokumente. +

      + + +
      +
      +
      + + + + + + +
      + Sovereign AI · Deutscher Bunker · Qognio · Deine Daten bleiben bei dir +
      +
      +
      + + + diff --git a/src/config.yaml b/src/config.yaml new file mode 100644 index 0000000..0498bfb --- /dev/null +++ b/src/config.yaml @@ -0,0 +1,43 @@ +slug: physio-tutor +bot_name: Luna +bot_title: PhysioTutor +brand_letter: L +title: "Luna · PhysioTutor" +tagline: "PhysioTutor" +tagline_short: "PhysioTutor" +meta_description: "Luna — dein KI-PhysioTutor. Gamified lernen mit Chat, Quiz, Flashcards und Fortschritts-Tracking. Läuft im deutschen Rechenzentrum." +bot_key_var: __LUNA_KEY__ +bot_key_value: qb_drfbhe3w6j2r7199w2 +bot_id: r3816r760kur14i +ls_prefix: luna +bot_version: "2026-04-21" + +# Color theme (Luna — purple) +accent: "#a855f7" +accent_2: "#a855f7" +accent_dark: "#7c3aed" +accent_rgb: "168, 85, 247" +success_color: "#10b981" +msg_strong_color: "#ddd6fe" + +# UI Labels +tab_flash_label: Karten +tab_curriculum_label: Lehrplan +curriculum_long_label: Lehrplan + +# Bot personality +quiz_intro_hint: "Wähle ein Modul — Luna generiert 10 Multiple-Choice-Fragen." +quiz_verb: erstellt +quiz_noun: "Fragen" +flash_intro_hint: "Luna erstellt Karteikarten zu einem Thema. Bewerte dein Erinnerungsvermögen — das System wiederholt schwere Karten öfter (SM-2)." +flash_verb: erstellt + +# DRIFT NOTE: Luna ist V1 (pre-module-tracking) und KANN NICHT diff-clean +# rendered werden ohne Code-Migration. Der Live-Bot: +# - hat KEIN moduleCorrect/moduleTotal/modulePassedFlash state +# - hat NUR 6 levels (kein 5000-XP Senior) +# - simpler checkBadges (5 generische badges) +# - kein Module-Completion-Bonus-XP +# Render erzeugt V2-Code; das ist eine "feature uplift", nicht ein Bugfix. +# Alle XP/Streak-Daten der bestehenden Luna-User bleiben kompatibel +# (extra fields werden initialisiert mit Defaults beim Load). diff --git a/src/curricula.json b/src/curricula.json new file mode 100644 index 0000000..4082be8 --- /dev/null +++ b/src/curricula.json @@ -0,0 +1,565 @@ +{ + "version": "2026-04-21", + "updated": "2026-04-21", + "curricula": [ + { + "id": "physiologie-uke", + "title": "Physiologie (UKE Hamburg)", + "short": "UKE iMED + Biomedizin", + "icon": "pulse", + "color": "#ef4444", + "description": "Integrierte Physiologie im UKE-Modellstudiengang iMED. 7 Systemmodule (A–G) mit Herz-Kreislauf, Atmung, Niere, Neuro, Endokrin.", + "modules": [ + { + "id": "zellphysiologie", + "title": "Zellphysiologie & erregbare Zellen", + "objectives": [ + "Ruhemembranpotenzial & Nernst-Gleichung erklären", + "Aktionspotenzial-Phasen beschreiben", + "Synaptische Übertragung (EPSP/IPSP)", + "Na+/K+-ATPase, Kanäle, Transporter" + ], + "topics": ["Membranpotenzial", "Aktionspotenzial", "Synapse", "Ionenkanäle", "Signaltransduktion"] + }, + { + "id": "muskelphysiologie", + "title": "Muskelphysiologie", + "objectives": [ + "Gleitfilamenttheorie & elektromechanische Kopplung", + "Skelett-, Herz-, glatte Muskulatur differenzieren", + "Kraft-Längen- und Kraft-Geschwindigkeits-Kurve", + "Motorische Einheit & Rekrutierung" + ], + "topics": ["Sarkomer", "Aktin-Myosin", "Tetanus", "Ermüdung", "Motor unit"] + }, + { + "id": "herz-kreislauf", + "title": "Herz-Kreislauf-Physiologie", + "objectives": [ + "Herzzyklus & Wiggers-Diagramm interpretieren", + "EKG-Ableitungen & normale Komplexe erkennen", + "Frank-Starling-Mechanismus erklären", + "Blutdruckregulation kurz-/langfristig", + "Mikrozirkulation & Starling-Gleichgewicht" + ], + "topics": ["Herzzyklus", "EKG", "Kontraktilität", "RAAS", "Barorezeptoren", "Kapillaren"] + }, + { + "id": "atmung", + "title": "Atmung & Gasaustausch", + "objectives": [ + "Lungenvolumina & Spirometrie interpretieren", + "Compliance, Resistance, Surfactant", + "Gastransport mit O2-Bindungskurve und Bohr-Effekt", + "Säure-Basen-Haushalt & renal/respiratorische Kompensation" + ], + "topics": ["FRC", "Totraum", "V/Q", "Hämoglobin", "pH", "Bikarbonat"] + }, + { + "id": "niere", + "title": "Niere & Wasser-Elektrolyt-Haushalt", + "objectives": [ + "GFR-Konzept mit Clearance (Inulin, Kreatinin)", + "Tubuläre Reabsorption & Sekretion pro Abschnitt", + "Gegenstromprinzip & ADH", + "RAAS, Blutvolumenregulation" + ], + "topics": ["Nephron", "Clearance", "ADH", "Aldosteron", "Elektrolyte"] + }, + { + "id": "endokrin", + "title": "Endokrinologie", + "objectives": [ + "Hypothalamus-Hypophysen-Achse", + "Schilddrüse, Nebenniere, Pankreas-Inseln", + "Glucose-Homöostase Insulin/Glukagon", + "Ca-Regulation (PTH, Calcitriol, Calcitonin)" + ], + "topics": ["Hormone", "Insulin", "Cortisol", "Schilddrüse", "Calcium"] + }, + { + "id": "nervensystem", + "title": "Nervensystem & Sinne", + "objectives": [ + "Aufbau ZNS/PNS, vegetatives NS", + "Somatosensorik, Schmerz, propriozeptive Wahrnehmung", + "Visuelles, auditives und vestibuläres System", + "Motorisches System: Pyramidenbahn + Kleinhirn" + ], + "topics": ["Sympathikus", "Parasympathikus", "Nozizeption", "Vestibular", "Pyramidenbahn"] + }, + { + "id": "blut", + "title": "Blut & Hämostase", + "objectives": [ + "Erythropoese, Hämoglobin-Varianten", + "Blutgruppen ABO + Rh", + "Primäre & sekundäre Hämostase", + "Fibrinolyse" + ], + "topics": ["Erythrozyten", "ABO", "Thrombozyten", "Gerinnungskaskade"] + } + ] + }, + { + "id": "physiotherapie-aprv", + "title": "Physiotherapie (PhysTh-APrV)", + "short": "Staatliche Ausbildung 2900/1600 h", + "icon": "activity", + "color": "#a855f7", + "description": "Bundesgesetzliche Grundlage für die 3-jährige Physiotherapie-Ausbildung. 2.900 h Theorie + 1.600 h Praxis. Staatliche Prüfung.", + "modules": [ + { + "id": "grundlagen", + "title": "Berufsgrundlagen & Recht", + "objectives": [ + "PhysTh-APrV-Struktur kennen", + "Hygienerichtlinien anwenden", + "Erste Hilfe sicher durchführen", + "Berufs- und Gesetzeskunde" + ], + "topics": ["APrV", "Hygiene", "Erste Hilfe", "Berufsrecht"], + "hours": 100 + }, + { + "id": "anatomie-physio-aprv", + "title": "Anatomie (240 h) & Physiologie (140 h)", + "objectives": [ + "Bewegungsapparat detailliert", + "Innere Organe Situs", + "Neuroanatomie Grundlagen", + "Physiologische Regelkreise" + ], + "topics": ["Muskeln", "Gelenke", "Nerven", "Organe"], + "hours": 380 + }, + { + "id": "krankheitslehre", + "title": "Spezielle Krankheitslehre (360 h)", + "objectives": [ + "Orthopädische Krankheitsbilder", + "Neurologische Syndrome erkennen", + "Innere Medizin Grundlagen", + "Chirurgie und Traumatologie" + ], + "topics": ["Innere", "Ortho", "Neuro", "Chirurgie", "Päd", "Psychiatrie", "Gyn", "Dermato", "Geriatrie", "Rheuma", "Arbeitsmed", "Sportmed"], + "hours": 360 + }, + { + "id": "bewegungslehre", + "title": "Bewegungslehre & Trainingslehre", + "objectives": [ + "Kinematik, Kinetik, biomechanische Prinzipien", + "Trainingsplanung aufbauen", + "Belastungssteuerung (FITT)", + "Motorisches Lernen" + ], + "topics": ["Biomechanik", "Ausdauer", "Kraft", "Koordination", "Beweglichkeit"], + "hours": 160 + }, + { + "id": "befund", + "title": "Befund- und Untersuchungstechniken (100 h)", + "objectives": [ + "Strukturierte Anamnese", + "Gelenkmessung Neutral-Null", + "Muskelfunktionstest (Janda)", + "Spezielle Tests (z. B. Lachman, O'Brien, Thomas)", + "ICF-Dokumentation" + ], + "topics": ["Anamnese", "Inspektion", "Palpation", "ROM", "MFT", "Spezialtests"], + "hours": 100 + }, + { + "id": "kg-techniken", + "title": "Krankengymnastische Behandlungstechniken (500 h)", + "objectives": [ + "Manuelle Therapie (Kaltenborn, Maitland, Mulligan)", + "PNF verstehen und anwenden", + "Bobath, Vojta für Neuro", + "Atemtherapie", + "Schroth bei Skoliose, McKenzie", + "MTT & gerätegestütztes Training" + ], + "topics": ["MT", "PNF", "Bobath", "Vojta", "Schroth", "McKenzie", "Atemtherapie", "MTT"], + "hours": 500 + }, + { + "id": "massage-physik", + "title": "Massage & Physikalische Therapie", + "objectives": [ + "Klassische Massage & Reflexzonen", + "Lymphdrainage nach Vodder", + "Elektrotherapie (TENS, Iontophorese, Galvanik)", + "Hydro-, Balneo-, Thermotherapie", + "Ultraschall, Licht/Strahlen" + ], + "topics": ["Massage", "MLD", "TENS", "Ultraschall", "Fango", "Inhalation"], + "hours": 270 + }, + { + "id": "methodische-anwendung", + "title": "Methodische Anwendung in Fachgebieten (700 h)", + "objectives": [ + "Behandlungsaufbau in Orthopädie, Chirurgie, Innerer Medizin", + "Neurorehabilitation (Schlaganfall, MS, Parkinson)", + "Pädiatrie (Bobath-Säugling, ICP)", + "Psychiatrie (konzentrative Bewegungstherapie)", + "Gynäkologie (Rückbildung)" + ], + "topics": ["Ortho-Reha", "Innere-Reha", "Neuro-Reha", "Päd", "Geriatrie", "Gyn"], + "hours": 700 + }, + { + "id": "pruefung", + "title": "Staatliche Prüfung", + "objectives": [ + "4 schriftliche Aufsichtsarbeiten bestehen", + "3 mündliche Prüfungen (Anatomie, Physiologie, spez. Krankheitslehre)", + "3 praktische Prüfungen an Patient:innen" + ], + "topics": ["Schriftlich", "Mündlich", "Praktisch"] + } + ] + }, + { + "id": "pflegeschule-flensburg", + "title": "Pflege (ÖBiZ/DIAKO Flensburg)", + "short": "Generalistische Pflegeausbildung", + "icon": "heart", + "color": "#ec4899", + "description": "Generalistische Pflegeausbildung nach PflBG/PflAPrV: 2.100 h Theorie + 2.500 h Praxis, 11 curriculare Einheiten, 5 Kompetenzbereiche.", + "modules": [ + { + "id": "ce01", + "title": "CE 01: Ausbildungsstart & wissenschaftliches Fundament", + "objectives": [ + "Pflegeverständnis entwickeln", + "Pflegeprozess (6 Schritte) anwenden", + "Berufsidentität reflektieren" + ], + "topics": ["Pflegeverständnis", "Pflegeprozess", "Berufsrolle"] + }, + { + "id": "ce02", + "title": "CE 02: Hochbelastete & krisenhafte Situationen", + "objectives": [ + "Reanimation durchführen (BLS + AED)", + "Akute Verwirrtheit erkennen", + "Sterbebegleitung gestalten" + ], + "topics": ["Reanimation", "Delir", "Palliativpflege"] + }, + { + "id": "ce03", + "title": "CE 03: Verstehens- und Aushandlungsprozesse", + "objectives": [ + "Patientengespräch gestalten", + "Beobachtung strukturieren", + "Reflexion eigener Einstellungen" + ], + "topics": ["Kommunikation", "Empathie", "Reflexion"] + }, + { + "id": "ce04", + "title": "CE 04: Gesundheitsförderung & Prävention", + "objectives": [ + "Primär-/Sekundär-/Tertiärprävention", + "Lebensstil-Beratung (Bewegung, Ernährung)", + "Impfungen nach STIKO" + ], + "topics": ["Gesundheitsförderung", "Prävention", "Beratung"] + }, + { + "id": "ce05", + "title": "CE 05: Kurative Prozesse & Patientensicherheit", + "objectives": [ + "Medikamenten-Management", + "OP-Vorbereitung und Nachsorge", + "Fehlervermeidung / CIRS" + ], + "topics": ["Medikation", "Perioperativ", "Patientensicherheit"] + }, + { + "id": "ce06", + "title": "CE 06: Akutsituationen", + "objectives": [ + "Notfälle erkennen und erstversorgen", + "Intensivpflege-Basics", + "Monitoring" + ], + "topics": ["Notfall", "ITS", "Monitoring"] + }, + { + "id": "ce07", + "title": "CE 07: Rehabilitation & chronische Erkrankungen", + "objectives": [ + "Rehabilitative Pflege", + "Krankheits-Selbstmanagement fördern", + "Expertenstandards anwenden (Mobilität, Schmerz, Wunden)" + ], + "topics": ["Reha", "Chronisch", "Expertenstandards"] + }, + { + "id": "ce08", + "title": "CE 08: Kritische Lebenssituationen", + "objectives": [ + "Demenzbegleitung", + "Psychiatrische Settings", + "Sucht, Gewalt, Suizidalität" + ], + "topics": ["Demenz", "Psychiatrie", "Sucht"] + }, + { + "id": "ce09", + "title": "CE 09: Eintritt in neue Lebensphasen", + "objectives": [ + "Pädiatrie: Säuglings- und Kinderpflege", + "Wochenbettpflege", + "Geriatrische Eintritts- und Übergangsphasen" + ], + "topics": ["Kinder", "Wochenbett", "Geriatrie"] + }, + { + "id": "ce10", + "title": "CE 10: Kognitive & psychische Beeinträchtigungen", + "objectives": [ + "Demenz-spezifische Pflege (DNQP-Standard)", + "Herausforderndes Verhalten verstehen", + "Milieu- und Biografiearbeit" + ], + "topics": ["Demenz", "Depression", "Verhalten"] + }, + { + "id": "ce11", + "title": "CE 11: Berufliches Selbstverständnis", + "objectives": [ + "Professionalisierung", + "Resilienz", + "Supervision nutzen" + ], + "topics": ["Professionalität", "Resilienz", "Supervision"] + }, + { + "id": "standards", + "title": "Expertenstandards DNQP", + "objectives": [ + "9 Standards kennen und anwenden", + "Dekubitus-, Sturz-, Schmerz-, Wund-, Kontinenz-, Ernährungs-, Entlassungs-, Mobilitäts-, Demenz-Standard" + ], + "topics": ["Dekubitus", "Sturz", "Schmerz", "Wunden", "Kontinenz", "Ernährung", "Entlassung", "Mobilität", "Demenz"] + } + ] + }, + { + "id": "medizinische-terminologie", + "title": "Medizinische Terminologie", + "short": "Latein/Griechisch + Wortbildung", + "icon": "book", + "color": "#f59e0b", + "description": "Pflichtkurs 1. Semester: lateinisch/griechische Fachsprache, Wortbildung, Nomina Anatomica, klinische Terminologie.", + "modules": [ + { + "id": "geschichte", + "title": "Geschichte der Fachsprache", + "objectives": [ + "Hippokrates, Galen, Vesalius verorten", + "Nomina Anatomica / Terminologia Anatomica kennen", + "Rolle des Latein vs. Englisch" + ], + "topics": ["Hippokrates", "Galen", "TA", "Nomenklatur"] + }, + { + "id": "grammatik", + "title": "Lateinische Grammatik (Basics)", + "objectives": [ + "5 Deklinationen sicher beherrschen", + "Adjektiv-Substantiv-Kongruenz", + "Nominativ & Genitiv im Fachbegriff" + ], + "topics": ["Deklination", "Kasus", "Adjektiv", "Plural"] + }, + { + "id": "wortbildung", + "title": "Wortbildung", + "objectives": [ + "Präfix + Stamm + Suffix identifizieren", + "Verbindungsvokale (o/i) richtig setzen", + "Komposita zerlegen" + ], + "topics": ["Präfix", "Suffix", "Kompositum", "Stamm"] + }, + { + "id": "praefixe", + "title": "Wichtige Präfixe", + "objectives": [ + "Griechische Präfixe (a-, dys-, hyper-, hypo-, tachy-, brady-)", + "Lateinische Präfixe (sub-, supra-, inter-, intra-, retro-)", + "Bedeutungsnuancen unterscheiden" + ], + "topics": ["a-/an-", "dys-", "hyper-/hypo-", "tachy-/brady-", "sub-/supra-", "inter-/intra-"] + }, + { + "id": "suffixe", + "title": "Wichtige Suffixe", + "objectives": [ + "-itis vs -ose (entzündlich vs nicht)", + "-ektomie, -otomie, -stomie (operative Eingriffe)", + "-algie, -rrhagie, -ämie" + ], + "topics": ["-itis", "-ose", "-ektomie", "-algie", "-rrhö"] + }, + { + "id": "wortstaemme", + "title": "Wortstämme (Organe)", + "objectives": [ + "Herz (cardi-), Niere (nephr-/ren-), Lunge (pneumo-/pulmo-)", + "Darm (enter-), Leber (hepat-), Magen (gastr-)", + "Synonymie Latein/Griechisch" + ], + "topics": ["cardi-", "nephr-", "hepat-", "gastr-", "neuro-", "arthr-"] + }, + { + "id": "lagebeziehungen", + "title": "Lage & Richtung", + "objectives": [ + "superior/inferior, anterior/posterior etc.", + "3 Ebenen (sagittal, frontal, transversal)", + "proximal/distal am Gliedmaß" + ], + "topics": ["Richtungen", "Ebenen", "Achsen"] + } + ] + }, + { + "id": "anatomie-grundlagen", + "title": "Anatomie & medizinische Grundlagen", + "short": "Vorklinik-Basics für alle", + "icon": "user", + "color": "#10b981", + "description": "Grundlagen der Anatomie, allgemeinen Pathologie, klinischen Untersuchung, Hygiene, Pharmakologie — Querschnitt für alle 4 Kern-Curricula.", + "modules": [ + { + "id": "allg-anatomie", + "title": "Allgemeine Anatomie", + "objectives": [ + "Gewebe (Epithel, Binde, Knorpel, Knochen, Muskel, Nerv)", + "Lagebezeichnungen und Ebenen", + "Bewegungsrichtungen benennen" + ], + "topics": ["Gewebe", "Ebenen", "Bewegungen"] + }, + { + "id": "bewegungsapparat", + "title": "Bewegungsapparat", + "objectives": [ + "Knochen der oberen und unteren Extremität", + "Wichtigste Muskeln mit Ursprung, Ansatz, Funktion", + "Gelenktypen erkennen und beschreiben" + ], + "topics": ["Knochen", "Muskeln", "Gelenke", "Wirbelsäule"] + }, + { + "id": "situs", + "title": "Situs (innere Organe)", + "objectives": [ + "Thorax-Organe in Lagebeziehung", + "Abdomen Ober-/Mittel-/Unterbauch", + "Retroperitoneale Organe" + ], + "topics": ["Thorax", "Abdomen", "Retroperitoneal"] + }, + { + "id": "neuroanatomie", + "title": "Neuroanatomie", + "objectives": [ + "Aufbau Gehirn (Lappen, Hirnstamm, Cerebellum)", + "Rückenmark-Gliederung (31 Segmente)", + "12 Hirnnerven benennen", + "Plexus: cervicalis, brachialis, lumbalis, sacralis" + ], + "topics": ["Gehirn", "Rückenmark", "Hirnnerven", "Plexus"] + }, + { + "id": "biochemie-basics", + "title": "Biochemie-Basics", + "objectives": [ + "Makromoleküle (Kohlenhydrate, Lipide, Proteine, NS)", + "Zitratzyklus und Atmungskette", + "Glukose-ATP-Bilanz" + ], + "topics": ["Stoffwechsel", "ATP", "Aminosäuren", "Fettsäuren"] + }, + { + "id": "pathologie-basics", + "title": "Allgemeine Pathologie", + "objectives": [ + "Nekrose vs. Apoptose", + "Entzündung: 5 Kardinalzeichen", + "Tumorlehre (TNM, Grading)", + "Ischämie, Thrombose, Ödem" + ], + "topics": ["Zelltod", "Entzündung", "Tumor", "Infarkt"] + }, + { + "id": "klinische-untersuchung", + "title": "Klinische Untersuchung", + "objectives": [ + "Strukturierte Anamnese (OPQRST)", + "Vitalzeichen interpretieren", + "Inspektion, Palpation, Perkussion, Auskultation" + ], + "topics": ["Anamnese", "Vitals", "Untersuchung"] + }, + { + "id": "hygiene", + "title": "Hygiene", + "objectives": [ + "5 Indikationen der Händedesinfektion (WHO)", + "Isolationsarten (Kontakt/Tröpfchen/Aerogen)", + "Sterilisation vs. Desinfektion" + ], + "topics": ["Händehygiene", "Isolation", "Sterilisation"] + }, + { + "id": "pharma-basics", + "title": "Pharmakologie-Basics", + "objectives": [ + "LADMET-Prinzip", + "Wichtige Wirkstoffgruppen (NSAR, Antikoagulantien, Antihypertensiva)", + "Kontraindikationen und Interaktionen" + ], + "topics": ["Kinetik", "Dynamik", "Wirkstoffgruppen"] + }, + { + "id": "notfall", + "title": "Notfall-Basics", + "objectives": [ + "BLS-Algorithmus (ERC)", + "Stabile Seitenlage", + "FAST bei Schlaganfall" + ], + "topics": ["Reanimation", "Seitenlage", "Schlaganfall"] + } + ] + } + ], + "badges": [ + {"id": "first_quiz", "title": "Erster Quiz-Durchlauf", "icon": "award", "description": "Du hast dein erstes Quiz absolviert."}, + {"id": "10_quiz_streak", "title": "10er-Serie", "icon": "flame", "description": "10 richtige Antworten in Folge."}, + {"id": "100_answers", "title": "Zentner", "icon": "star", "description": "100 Antworten insgesamt gegeben."}, + {"id": "7_day_streak", "title": "Wochen-Streak", "icon": "calendar", "description": "7 Tage in Folge aktiv."}, + {"id": "curriculum_complete", "title": "Curriculum-Meister", "icon": "crown", "description": "Ein Curriculum vollständig durchgearbeitet."}, + {"id": "night_owl", "title": "Nachteule", "icon": "moon", "description": "Nach 22 Uhr gelernt."}, + {"id": "early_bird", "title": "Frühaufsteher", "icon": "sun", "description": "Vor 7 Uhr gelernt."} + ], + "levels": [ + {"min": 0, "title": "Anfängerin"}, + {"min": 50, "title": "Einsteigerin"}, + {"min": 200, "title": "Fortgeschrittene"}, + {"min": 500, "title": "Profi"}, + {"min": 1250, "title": "Expertin"}, + {"min": 2500, "title": "Meisterin"}, + {"min": 5000, "title": "Großmeisterin"} + ] +} diff --git a/src/levels-fallback.js b/src/levels-fallback.js new file mode 100644 index 0000000..2488e17 --- /dev/null +++ b/src/levels-fallback.js @@ -0,0 +1,3 @@ + { min: 0, title: 'Anfänger:in' }, { min: 50, title: 'Einsteiger:in' }, + { min: 200, title: 'Fortgeschrittene:r' }, { min: 500, title: 'Profi' }, + { min: 1250, title: 'Expert:in' }, { min: 2500, title: 'Meister:in' } diff --git a/src/welcome.html b/src/welcome.html new file mode 100644 index 0000000..7b5a772 --- /dev/null +++ b/src/welcome.html @@ -0,0 +1,25 @@ +

      Willkommen bei Luna!

      +

      Ich bin dein:e KI-Tutor:in für Physiotherapie, Pflege, Physiologie & Anatomie. Alles läuft im deutschen Rechenzentrum — keine Daten verlassen Europa.

      +
      + + + + + +
      +

      In 3 Sätzen: Chat für Verständnis → Quiz zum Testen → Flashcards zum Merken. Fortschritt zeigt dir, was schon sitzt; der Lehrplan gibt Orientierung.

      \ No newline at end of file diff --git a/www/app.js b/www/app.js new file mode 100644 index 0000000..60238f7 --- /dev/null +++ b/www/app.js @@ -0,0 +1,1803 @@ +/* Luna — PhysioTutor Widget + * Vanilla JS, no build, no framework, keine externen Fonts/Analytics. + * Chat | Quiz | Flashcards | Fortschritt | Lehrplan — localStorage only. + */ +(() => { + 'use strict'; + + // ==== Config ==== + const API = 'https://llm.qognio.com/api/bots/physio-tutor/chat'; + const RAW_KEY = window.__LUNA_KEY__ || ''; + const KEY = /^qb_[a-zA-Z0-9]{6,}$/.test(RAW_KEY) ? RAW_KEY : ''; + const LS_KEY = 'luna.state.v1'; + const LS_CHAT = 'luna.chat.v1'; + const LS_FLASH = 'luna.flash.v1'; + + // ==== State ==== + let CURRICULA = null; + let state = loadState(); + let chatHistory = loadChatHistory(); + let flashCards = loadFlashCards(); // { [topicId]: [{front, back, hint, ef, interval, due, reps}] } + + // ==== Utils ==== + const $ = (s, r = document) => r.querySelector(s); + const $$ = (s, r = document) => Array.from(r.querySelectorAll(s)); + const now = () => Date.now(); + const today = () => { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + }; + function daysBetween(dateA, dateB) { + const a = new Date(dateA); const b = new Date(dateB); + return Math.round((b - a) / 86400000); + } + + // ==== Persistence ==== + function loadState() { + try { + const s = JSON.parse(localStorage.getItem(LS_KEY) || '{}'); + return { + xp: s.xp || 0, + totalAnswers: s.totalAnswers || 0, + correctAnswers: s.correctAnswers || 0, + currentStreak: s.currentStreak || 0, + maxStreak: s.maxStreak || 0, + lastActive: s.lastActive || null, + quizStreak: s.quizStreak || 0, + maxQuizStreak: s.maxQuizStreak || 0, + mastery: s.mastery || {}, // { [curriculumId]: { correct, total } } + moduleCorrect: s.moduleCorrect || {}, // { [moduleId]: correctCount } + moduleTotal: s.moduleTotal || {}, // { [moduleId]: totalAnswered } + modulePassedFlash: s.modulePassedFlash || {}, // { [moduleId]: true when all cards rated >= "gut"} + completedQuizzes: s.completedQuizzes || 0, + flashCardsRated: s.flashCardsRated || 0, + badges: s.badges || {}, + seenWelcome: s.seenWelcome || false, + completedCurricula: s.completedCurricula || [] + }; + } catch (e) { + return { + xp: 0, totalAnswers: 0, correctAnswers: 0, + currentStreak: 0, maxStreak: 0, lastActive: null, + quizStreak: 0, maxQuizStreak: 0, mastery: {}, + moduleCorrect: {}, moduleTotal: {}, modulePassedFlash: {}, + completedQuizzes: 0, flashCardsRated: 0, + badges: {}, seenWelcome: false, completedCurricula: [] + }; + } + } + function saveState() { localStorage.setItem(LS_KEY, JSON.stringify(state)); } + + function loadChatHistory() { + try { return JSON.parse(localStorage.getItem(LS_CHAT) || '[]'); } catch (e) { return []; } + } + function saveChatHistory() { + // Cap at last 40 exchanges to keep API payload small + const trimmed = chatHistory.slice(-40); + localStorage.setItem(LS_CHAT, JSON.stringify(trimmed)); + } + + function loadFlashCards() { + try { return JSON.parse(localStorage.getItem(LS_FLASH) || '{}'); } catch (e) { return {}; } + } + function saveFlashCards() { localStorage.setItem(LS_FLASH, JSON.stringify(flashCards)); } + + // ==== Streak / activity tracking ==== + function touchActivity() { + const t = today(); + if (state.lastActive === t) return; + if (state.lastActive) { + const diff = daysBetween(state.lastActive, t); + if (diff === 1) state.currentStreak += 1; + else if (diff > 1) state.currentStreak = 1; + else state.currentStreak = Math.max(1, state.currentStreak); + } else { + state.currentStreak = 1; + } + state.lastActive = t; + if (state.currentStreak > state.maxStreak) state.maxStreak = state.currentStreak; + checkBadges(); + saveState(); + } + + // ==== XP / Level ==== + function addXP(n, reason = '') { + state.xp += n; + saveState(); + showXPGain(`+${n} XP${reason ? ' · ' + reason : ''}`); + } + function levelInfo() { + const levels = (CURRICULA && CURRICULA.levels) || [ + { min: 0, title: 'Anfänger:in' }, { min: 50, title: 'Einsteiger:in' }, + { min: 200, title: 'Fortgeschrittene:r' }, { min: 500, title: 'Profi' }, + { min: 1250, title: 'Expert:in' }, { min: 2500, title: 'Meister:in' } + ]; + let cur = levels[0]; + for (const l of levels) if (state.xp >= l.min) cur = l; + const idx = levels.indexOf(cur); + const next = levels[idx + 1] || null; + const pct = next ? Math.min(100, ((state.xp - cur.min) / (next.min - cur.min)) * 100) : 100; + return { levelNum: idx + 1, title: cur.title, pct, next }; + } + + // ==== Badges ==== + function unlockBadge(id) { + if (state.badges[id]) return false; + state.badges[id] = today(); + saveState(); + const badge = (CURRICULA && CURRICULA.badges || []).find(b => b.id === id); + if (badge) toast('🏆 Neues Abzeichen: ' + badge.title, 'success', 4500); + return true; + } + function checkBadges() { + if (state.completedQuizzes >= 1) unlockBadge('first_quiz'); + if (state.maxQuizStreak >= 10) unlockBadge('10_quiz_streak'); + if (state.totalAnswers >= 100) unlockBadge('100_answers'); + if (state.maxStreak >= 7) unlockBadge('7_day_streak'); + if (state.completedCurricula.length >= 1) unlockBadge('curriculum_complete'); + const h = new Date().getHours(); + if (h >= 22) unlockBadge('night_owl'); + if (h < 7) unlockBadge('early_bird'); + } + + // ==== Toast ==== + function toast(msg, kind = '', ms = 3200) { + const stack = $('#toast-stack'); + const t = document.createElement('div'); + t.className = 'toast ' + kind; + t.textContent = msg; + stack.appendChild(t); + setTimeout(() => { + t.style.opacity = '0'; + t.style.transition = 'opacity .25s'; + setTimeout(() => t.remove(), 260); + }, ms); + } + function showXPGain(txt) { + const el = document.createElement('div'); + el.className = 'xp-gain'; + el.textContent = txt; + document.body.appendChild(el); + setTimeout(() => el.remove(), 1600); + } + + // ==== Simple markdown renderer ==== + function renderMD(md) { + if (!md) return ''; + let s = md; + // Escape HTML first + s = s.replace(/&/g, '&').replace(//g, '>'); + // Code fences + s = s.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) => + `
      ${code}
      `); + // Inline code + s = s.replace(/`([^`\n]+)`/g, '$1'); + // GFM tables: header / separator / body + s = s.replace(/(?:^|\n)((?:\|[^\n]*\|[ \t]*\n){2,})/g, (block, content) => { + const lines = content.trim().split('\n'); + if (lines.length < 2) return block; + const sep = lines[1]; + if (!/^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/.test(sep)) return block; + const parseRow = (ln) => ln.replace(/^\|/, '').replace(/\|\s*$/, '').split('|').map(c => c.trim()); + const header = parseRow(lines[0]); + const aligns = parseRow(sep).map(s => /^:-+:$/.test(s) ? 'center' : /-+:$/.test(s) ? 'right' : 'left'); + const rows = lines.slice(2).map(parseRow); + let html = '\n'; + header.forEach((h, i) => { html += ``; }); + html += ''; + rows.forEach(r => { + html += ''; + for (let i = 0; i < Math.max(r.length, header.length); i++) { + html += ``; + } + html += ''; + }); + html += '
      ${h}
      ${r[i] || ''}
      \n'; + return html; + }); + // Bold + s = s.replace(/\*\*([^*\n]+)\*\*/g, '$1'); + s = s.replace(/__([^_\n]+)__/g, '$1'); + // Italic + s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2'); + // Headings + s = s.replace(/^### (.+)$/gm, '

      $1

      '); + s = s.replace(/^## (.+)$/gm, '

      $1

      '); + s = s.replace(/^# (.+)$/gm, '

      $1

      '); + // Links + s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '$1'); + // Unordered lists + s = s.replace(/(?:^|\n)((?:[*\-] .+\n?)+)/g, (m) => { + const items = m.trim().split('\n').map(ln => ln.replace(/^[*\-] /, '')).map(li => `
    • ${li}
    • `).join(''); + return '\n
        ' + items + '
      '; + }); + // Ordered lists + s = s.replace(/(?:^|\n)((?:\d+\. .+\n?)+)/g, (m) => { + const items = m.trim().split('\n').map(ln => ln.replace(/^\d+\. /, '')).map(li => `
    • ${li}
    • `).join(''); + return '\n
        ' + items + '
      '; + }); + // Paragraphs: split on blank lines + s = s.split(/\n{2,}/).map(block => { + if (/^<(h\d|ul|ol|pre|blockquote|table)/.test(block.trim())) return block; + return '

      ' + block.replace(/\n/g, '
      ') + '

      '; + }).join('\n'); + return s; + } + + // ==== Chat API ==== + async function chatAPI(message, history, attachments) { + const body = { message, history }; + if (Array.isArray(attachments) && attachments.length > 0) body.attachments = attachments; + const r = await fetch(API, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + KEY + }, + body: JSON.stringify(body) + }); + let data; + try { data = await r.json(); } catch (e) { throw new Error('Server-Antwort nicht lesbar'); } + if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status)); + return data; + } + + // ==== Attachments (file upload) ==== + const ATTACH_MAX_COUNT = 5; + const ATTACH_MAX_BYTES = 8 * 1024 * 1024; + const ATTACH_ACCEPTED_RE = /\.(pdf|txt|md|csv|json|xml|yaml|yml|log|png|jpg|jpeg|webp|gif)$/i; + let pendingAttachments = []; // [{ name, mimeType, dataUrl, size }] + + function fmtSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / 1024 / 1024).toFixed(1) + ' MB'; + } + + function fileToDataUrl(file) { + return new Promise((resolve, reject) => { + const fr = new FileReader(); + fr.onload = () => resolve(fr.result); + fr.onerror = () => reject(new Error('Datei konnte nicht gelesen werden')); + fr.readAsDataURL(file); + }); + } + + function renderAttachStrip() { + const strip = $('#attach-strip'); + if (!strip) return; + strip.innerHTML = ''; + pendingAttachments.forEach((a, idx) => { + const chip = document.createElement('span'); + chip.className = 'attach-chip'; + const ico = a.mimeType.startsWith('image/') ? '🖼' : (a.mimeType === 'application/pdf' || /\.pdf$/i.test(a.name)) ? '📄' : '📝'; + chip.innerHTML = `${ico}${a.name.replace(/[<>"']/g, '')}${fmtSize(a.size)}`; + const rm = document.createElement('button'); + rm.type = 'button'; + rm.className = 'attach-chip-remove'; + rm.setAttribute('aria-label', 'Anhang entfernen'); + rm.textContent = '×'; + rm.addEventListener('click', () => { + pendingAttachments.splice(idx, 1); + renderAttachStrip(); + }); + chip.appendChild(rm); + strip.appendChild(chip); + }); + } + + async function addFiles(fileList) { + const files = Array.from(fileList || []); + for (const f of files) { + if (pendingAttachments.length >= ATTACH_MAX_COUNT) { + toast(`Max ${ATTACH_MAX_COUNT} Anhänge — weitere ignoriert`, 'warn'); + break; + } + if (!ATTACH_ACCEPTED_RE.test(f.name) && !f.type.startsWith('image/') && !/text|json|xml|pdf/i.test(f.type)) { + toast(`${f.name}: Format nicht unterstützt`, 'error'); + continue; + } + if (f.size > ATTACH_MAX_BYTES) { + toast(`${f.name}: ${fmtSize(f.size)} > Limit ${fmtSize(ATTACH_MAX_BYTES)}`, 'error'); + continue; + } + try { + const dataUrl = await fileToDataUrl(f); + pendingAttachments.push({ + name: f.name, + mimeType: f.type || 'application/octet-stream', + dataUrl, + size: f.size, + }); + } catch (e) { + toast(`${f.name}: ${e.message}`, 'error'); + } + } + renderAttachStrip(); + } + + // ==== Chat UI ==== + function addMsg(role, content, { markdown = false, pending = false, attachments = null } = {}) { + const box = $('#chat-box'); + const el = document.createElement('div'); + el.className = 'msg ' + role; + if (pending) { + el.innerHTML = ''; + } else if (markdown) { + el.innerHTML = renderMD(content); + } else { + el.textContent = content; + } + if (Array.isArray(attachments) && attachments.length > 0) { + const wrap = document.createElement('div'); + wrap.className = 'msg-attachments'; + attachments.forEach(a => { + const ico = a.mimeType && a.mimeType.startsWith('image/') ? '🖼' : (a.mimeType === 'application/pdf' || /\.pdf$/i.test(a.name)) ? '📄' : '📝'; + const span = document.createElement('span'); + span.className = 'att-name'; + span.textContent = `${ico} ${a.name} (${fmtSize(a.size)})`; + wrap.appendChild(span); + }); + el.appendChild(wrap); + } + box.appendChild(el); + box.scrollTop = box.scrollHeight; + setTimeout(() => { $('.main').scrollTop = $('.main').scrollHeight; }, 20); + return el; + } + + function clearChatUI() { + $('#chat-box').innerHTML = ''; + } + + function renderWelcome() { + if (state.xp === 0 && chatHistory.length === 0 && !state.seenWelcome) { + $('#welcome-screen').classList.remove('hidden'); + $('#welcome-screen').setAttribute('aria-hidden', 'false'); + } else { + $('#welcome-screen').classList.add('hidden'); + $('#welcome-screen').setAttribute('aria-hidden', 'true'); + } + } + + function restoreChat() { + clearChatUI(); + for (const m of chatHistory) { + addMsg(m.role === 'assistant' ? 'bot' : 'user', m.content, { markdown: m.role === 'assistant' }); + } + renderWelcome(); + } + + async function sendChat(text) { + const attaches = pendingAttachments.slice(); + if (!text.trim() && attaches.length === 0) return; + $('#welcome-screen').classList.add('hidden'); + state.seenWelcome = true; + + addMsg('user', text || '(nur Anhang)', { attachments: attaches }); + chatHistory.push({ + role: 'user', + content: text + (attaches.length ? '\n[Anhänge: ' + attaches.map(a => a.name).join(', ') + ']' : ''), + }); + pendingAttachments = []; + renderAttachStrip(); + + const pend = addMsg('bot', '', { pending: true }); + $('#composer-send').disabled = true; + + try { + const hist = chatHistory.slice(-20, -1); + const data = await chatAPI(text, hist, attaches.length ? attaches.map(a => ({ name: a.name, mimeType: a.mimeType, dataUrl: a.dataUrl })) : null); + pend.classList.remove('pending'); + const structured = _tryParseStructuredReply(data.reply || ''); + if (structured) { + pend.innerHTML = _renderStructuredInChat(structured); + } else { + pend.innerHTML = renderMD(data.reply || ''); + } + if (Array.isArray(data.attachment_notes) && data.attachment_notes.length) { + const notice = document.createElement('div'); + notice.className = 'attachment-notice'; + notice.textContent = '📎 ' + data.attachment_notes.join(' · '); + pend.appendChild(notice); + } + chatHistory.push({ role: 'assistant', content: data.reply }); + saveChatHistory(); + touchActivity(); + + // Soft-Gate: postMessage to parent window after N user→assistant + // exchanges (configurable via ?showcase=1 query param). The outer + // qognio.com/showcase page listens for this event and triggers the + // lead-form modal. Sent only when running in an iframe. + try { + const isShowcase = new URLSearchParams(window.location.search).get('showcase') === '1'; + if (isShowcase && window.parent && window.parent !== window) { + const userMessages = chatHistory.filter((m) => m.role === 'user').length; + const SOFT_GATE_AT = 3; + if (userMessages === SOFT_GATE_AT) { + window.parent.postMessage( + { type: 'qognio:soft-gate', afterMessages: userMessages, botSlug: window.location.hostname.split('.')[0] }, + '*' + ); + } + } + } catch (sgErr) { + // non-fatal — swallow + } + } catch (e) { + pend.className = 'msg sys'; + pend.textContent = '⚠ Fehler: ' + (e.message || 'unbekannt'); + toast('Verbindung fehlgeschlagen', 'error'); + } finally { + $('#composer-send').disabled = false; + $('#composer').focus(); + } + } + + // ==== Structured request helper ==== + function _extractJSON(reply) { + let s = (reply || '').trim(); + const fence = s.match(/```(?:json)?\s*([\s\S]*?)\s*```/); + if (fence) s = fence[1].trim(); + const b = s.indexOf('{'); const e = s.lastIndexOf('}'); + if (b >= 0 && e > b) s = s.slice(b, e + 1); + return s; + } + function _repairJSON(s) { + s = s.replace(/,(\s*[}\]])/g, '$1'); + s = s.replace(/,\s*,/g, ','); + s = s.replace(/(\})\s*\n\s*(\{)/g, '$1,\n$2'); + s = s.replace(/(")(\s*\n\s*)(")/g, '$1,$2$3'); + return s; + } + + // --- Structured-Reply-Fallback fuer Chat (2026-04-25) --- + // Wenn der Bot versehentlich QUIZ/FLASHCARD/CASE/EXAM-JSON liefert statt Markdown, + // parse + render hier lesbar statt Raw-JSON im Chat-Bubble anzuzeigen. + function _tryParseStructuredReply(reply) { + let s = (reply || '').trim(); + const fence = s.match(/```(?:json)?\s*([\s\S]*?)\s*```/); + if (fence) s = fence[1].trim(); + if (!s.startsWith('{')) return null; + const b = s.indexOf('{'), e = s.lastIndexOf('}'); + if (b < 0 || e <= b) return null; + const raw = s.slice(b, e + 1); + let obj; + try { obj = JSON.parse(raw); } catch { + try { obj = JSON.parse(_repairJSON(raw)); } catch { return null; } + } + if (!obj || typeof obj !== 'object') return null; + const KNOWN = [ + 'case','quiz','flashcards','exam','lesson','presentation', + // Bot-spezifische Strukturen (KURT/VESTIGIA/PAUL/Pia/Otto/Eli/LIMEN/Zita/LIBRA/IDA) + 'audit','privacy_check','mail_check','plan','validate','interview','decode','write','calc','unterweisung', + 'compliance_report' + ]; + if (!KNOWN.includes(obj.type)) return null; + return obj; + } + function _renderStructuredInChat(obj) { + const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + const label = ['A','B','C','D','E','F','G','H']; + const TYPE_BADGE = { + case:'📖 Fallbeispiel', quiz:'🧠 Quiz-Fragen', flashcards:'📇 Karteikarten', + exam:'📝 Prüfung', lesson:'🎓 Lektion', presentation:'🖼 Präsentation', + audit:'🛡 AI-Act Audit-Trail', privacy_check:'🩺 Pflege-Datenschutz-Check', + mail_check:'📧 Mail-Check', plan:'🗓 Plan', validate:'✓ Validierung', + interview:'🎙 Wissens-Interview', decode:'🔍 Zeugnis-Decoder', + write:'✍ Zeugnis-Entwurf', calc:'🧮 Kalkulation', + unterweisung:'🛠 AdA-Unterweisung', + compliance_report:'🔍 Website-Compliance-Check', + }; + let html = `
      ${TYPE_BADGE[obj.type] || '📄 Strukturierte Antwort'}
      `; + if (obj.topic) html += `
      ${esc(obj.topic)}
      `; + if (obj.type === 'case') { + if (obj.scenario) html += `
      Szenario:
      ${esc(obj.scenario)}
      `; + const fragen = obj.fragen || obj.questions || []; + fragen.forEach((f, i) => { + html += `
      Frage ${i+1}: ${esc(f.frage || f.q || '')}`; + const opts = f.options || []; + if (opts.length) { + html += '
        '; + opts.forEach((o, j) => { + const correct = (f.correct === j); + html += `${label[j]||(j+1)}) ${esc(o)}${correct ? ' ✓' : ''}`; + }); + html += '
      '; + } + const ex = f.explain || f.explanation; + if (ex) html += `
      Erklärung: ${esc(ex)}
      `; + html += '
      '; + }); + const lessons = obj.lessons || []; + if (lessons.length) { + html += '
      Lessons:
        '; + lessons.forEach(l => { html += `
      • ${esc(l)}
      • `; }); + html += '
      '; + } + const norm = obj.paragraphen || obj.normen || obj.artikel || []; + if (norm.length) { + html += '
      Rechtsnormen: ' + norm.map(n => `${esc(n)}`).join(' · ') + '
      '; + } + } else if (obj.type === 'quiz' || obj.type === 'exam') { + // Exam-spezifisch: Dauer-Anzeige + HF-Tag pro Frage (IDA AdA-Schein) + if (obj.type === 'exam' && obj.duration_min) { + html += `
      Dauer: ${esc(obj.duration_min)} Min · Fragen: ${(obj.questions||[]).length}
      `; + } + const qs = obj.questions || []; + qs.forEach((q, i) => { + const hfTag = (obj.type === 'exam' && q.hf != null) ? ` [HF ${esc(q.hf)}]` : ''; + html += `
      Frage ${i+1}:${hfTag} ${esc(q.q || q.frage || '')}`; + const opts = q.options || []; + if (opts.length) { + html += '
        '; + opts.forEach((o, j) => { + const correct = (q.correct === j); + html += `${label[j]||(j+1)}) ${esc(o)}${correct ? ' ✓' : ''}`; + }); + html += '
      '; + } + const ex = q.explain || q.explanation; + if (ex) html += `
      Erklärung: ${esc(ex)}
      `; + html += '
      '; + }); + } else if (obj.type === 'flashcards') { + (obj.cards || []).forEach((c, i) => { + html += `
      Karte ${i+1}: ${esc(c.front || '')}
      ${esc(c.back || '')}
      `; + if (c.hint) html += `
      Hinweis: ${esc(c.hint)}
      `; + html += '
      '; + }); + } else if (obj.type === 'lesson' || obj.type === 'presentation') { + if (obj.objectives || obj.learning_objectives) { + const objs = obj.objectives || obj.learning_objectives; + html += '
      Lernziele:
        '; + objs.forEach(o => { html += `
      • ${esc(o)}
      • `; }); + html += '
      '; + } + (obj.slides || []).forEach((s, i) => { + html += `
      ${i+1}. ${esc(s.title || '')}`; + if (s.content_md || s.content) html += `
      ${renderMD(s.content_md || s.content || '')}
      `; + if (s.key_point) html += `
      💡 ${esc(s.key_point)}
      `; + html += '
      '; + }); + } else if (obj.type === 'audit') { + // KURT / VESTIGIA — AI-Act Audit-Trail + if (obj.system) html += `
      System: ${esc(obj.system)}
      `; + const cls = obj.ai_act_risk_class || obj.risk_class; + if (cls) { + const clsColor = { prohibited:'#dc2626', high:'#dc2626', limited:'#eab308', minimal:'#22c55e' }[cls] || '#8b8a99'; + html += `
      Risiko-Klasse: ${esc(cls)}
      `; + } + if (obj.role) html += `
      Rolle: ${esc(obj.role)}
      `; + if (obj.dsgvo_relevant != null) html += `
      DSGVO-relevant: ${obj.dsgvo_relevant ? 'ja' : 'nein'}
      `; + if (obj.art22_check) html += `
      Art. 22 DSGVO: ${esc(obj.art22_check)}
      `; + const arts = obj.required_artifacts || []; + if (arts.length) { + html += '
      Erforderliche Artefakte:'; + arts.forEach(a => { + const sColor = { required:'#dc2626', optional:'#eab308', 'not-required':'#22c55e' }[a.status] || '#8b8a99'; + html += ``; + }); + html += '
      ArtefaktStatusBasis
      ${esc(a.name||'')}${esc(a.status||'')}${esc(a.based_on||'')}
      '; + } + const cw = obj.crosswalk_savings || []; + if (cw.length) { + html += '
      Crosswalk-Einsparung:
        '; + cw.forEach(c => { html += `
      • ${esc(c)}
      • `; }); + html += '
      '; + } + const dl = obj.deadlines || []; + if (dl.length) { + html += '
      Fristen:
        '; + dl.forEach(d => { html += `
      • ${esc(d.date||'')} — ${esc(d.what||'')}
      • `; }); + html += '
      '; + } + const ws = obj.warnings || obj.warnung || []; + if (ws.length) { + html += '
      ⚠ Warnungen:
        '; + ws.forEach(w => { html += `
      • ${esc(w)}
      • `; }); + html += '
      '; + } + } else if (obj.type === 'privacy_check') { + // PAUL — Pflege-Datenschutz-Ampel + const ampel = obj.ampel || 'yellow'; + const ampelColor = { green:'#22c55e', yellow:'#eab308', red:'#dc2626' }[ampel] || '#8b8a99'; + const ampelLabel = { green:'🟢 GRÜN — OK', yellow:'🟡 GELB — Vorsicht', red:'🔴 ROT — Akute Gefahr' }[ampel] || ampel; + html += `
      ${ampelLabel}
      `; + if (obj.situation) html += `
      Situation: ${esc(obj.situation)}
      `; + if (obj.schweigepflicht) html += `
      Schweigepflicht (§203 StGB): ${esc(obj.schweigepflicht)}
      `; + if (obj.dsgvo_basis) html += `
      DSGVO-Basis: ${esc(obj.dsgvo_basis)}
      `; + const acts = obj.handlung || obj.handlungen || []; + if (acts.length) { + html += '
      Handlung:
        '; + acts.forEach(a => { html += `
      1. ${esc(a)}
      2. `; }); + html += '
      '; + } + const wns = obj.warnung || obj.warnungen || []; + if (wns.length) { + html += `
      ⚠ Achtung:
        `; + wns.forEach(w => { html += `
      • ${esc(w)}
      • `; }); + html += '
      '; + } + } else if (obj.type === 'mail_check') { + // Pia — Phishing-Prüferin Mail-Bewertung + const ampel = obj.ampel || 'yellow'; + const ampelColor = { green:'#22c55e', yellow:'#eab308', red:'#dc2626' }[ampel] || '#8b8a99'; + const ampelLabel = { green:'🟢 GRÜN — vermutlich legitim', yellow:'🟡 GELB — verdächtig, zweite Meinung holen', red:'🔴 ROT — sehr wahrscheinlich Phishing/BEC' }[ampel] || ampel; + html += `
      ${ampelLabel}
      `; + if (obj.kurz_begruendung) html += `
      Kurz: ${esc(obj.kurz_begruendung)}
      `; + if (obj.pattern) html += `
      Pattern: ${esc(obj.pattern)}
      `; + const flags = obj.red_flags || []; + if (flags.length) { + html += '
      Red Flags:
        '; + flags.forEach(f => { html += `
      • ${esc(f)}
      • `; }); + html += '
      '; + } + const recs = obj.empfohlene_aktion || obj.aktionen || []; + if (recs.length) { + html += '
      Empfohlene Aktion:
        '; + recs.forEach(a => { html += `
      1. ${esc(a)}
      2. `; }); + html += '
      '; + } + if (obj.weiterleiten_an) html += `
      Weiterleiten an: ${esc(obj.weiterleiten_an)}
      `; + } else if (obj.type === 'plan') { + // Otto — 90-Tage-Onboarding-Plan + if (obj.role) html += `
      Rolle: ${esc(obj.role)}
      `; + const items = obj.weeks || obj.days || obj.steps || obj.phases || []; + if (items.length) { + items.forEach((it, i) => { + const label = it.day || it.week || it.phase || `Phase ${i+1}`; + const focus = it.focus || it.title || ''; + html += `
      ${esc(label)}${focus?` — ${esc(focus)}`:''}
      `; + const tasks = it.tasks || it.aufgaben || []; + if (tasks.length) { + html += '
        '; + tasks.forEach(t => { html += `
      • ${esc(typeof t === 'string' ? t : (t.task || t.text || JSON.stringify(t)))}
      • `; }); + html += '
      '; + } + if (it.success_signal || it.success) html += `
      ✓ ${esc(it.success_signal || it.success)}
      `; + html += '
      '; + }); + } + } else if (obj.type === 'validate') { + // Eli — E-Rechnungs-Validator + const status = obj.status || 'ok'; + const statusColor = { ok:'#22c55e', warnings:'#eab308', errors:'#dc2626' }[status] || '#8b8a99'; + const statusLabel = { ok:'✅ Validierung OK', warnings:'⚠ Warnungen', errors:'❌ Fehler — nicht konform' }[status] || status; + html += `
      ${statusLabel}
      `; + if (obj.file || obj.dateiname) html += `
      Datei: ${esc(obj.file || obj.dateiname)}
      `; + if (obj.format) html += `
      Format: ${esc(obj.format)}
      `; + const issues = obj.issues || obj.findings || []; + if (issues.length) { + html += '
      Befunde:'; + issues.forEach(i => { + const sev = i.severity || 'info'; + const sevColor = { error:'#dc2626', warning:'#eab308', info:'#06b6d4' }[sev] || '#8b8a99'; + html += ``; + }); + html += '
      SeverityMeldungFix
      ${esc(sev)}${esc(i.message||i.msg||'')}${esc(i.fix||'')}
      '; + } + } else if (obj.type === 'interview') { + // LIMEN — Wissens-Interview entlang Achse + if (obj.achse) html += `
      Wissens-Achse: ${esc(obj.achse)}
      `; + const fs = obj.fragen || obj.questions || []; + fs.forEach((f, i) => { + html += `
      Frage ${i+1}: ${esc(f.f || f.frage || f.q || '')}`; + if (f.tipp_aktiv_zuhören || f.tipp_zuhoeren) html += `
      👂 Tipp Aktiv-Zuhören: ${esc(f.tipp_aktiv_zuhören || f.tipp_zuhoeren)}
      `; + if (f.tipp_nachfass) html += `
      ↳ Tipp Nachfass: ${esc(f.tipp_nachfass)}
      `; + html += '
      '; + }); + } else if (obj.type === 'decode') { + // Zita — Zeugnis-Decoder + if (obj.zeugnis_text) html += `
      Zeugnis-Text:
      ${esc(obj.zeugnis_text)}
      `; + const gradeNum = (g) => { + const n = typeof g === 'number' ? g : parseFloat(String(g).replace(',','.')); + return isNaN(n) ? null : n; + }; + const gradeColor = (g) => { + const n = gradeNum(g); + if (n != null) return n <= 2 ? '#22c55e' : n <= 3 ? '#eab308' : '#dc2626'; + return ({ sehr_gut:'#22c55e', gut:'#22c55e', befriedigend:'#eab308', ausreichend:'#eab308', mangelhaft:'#dc2626', ungenuegend:'#dc2626' }[String(g||'')] || '#8b8a99'); + }; + if (obj.overall_grade != null && obj.overall_grade !== '') { + const ogColor = gradeColor(obj.overall_grade); + html += `
      Gesamt-Note: ${esc(obj.overall_grade)}${obj.grade_label ? ' — ' + esc(obj.grade_label) : ''}
      `; + } + // Sub-Noten (verhalten/schlussformel) + if (obj.verhalten_grade != null || obj.schlussformel_grade != null) { + html += '
      '; + if (obj.verhalten_grade != null) html += `Verhalten: ${esc(obj.verhalten_grade)} `; + if (obj.schlussformel_grade != null) html += ` · Schlussformel: ${esc(obj.schlussformel_grade)}`; + html += '
      '; + } + // Akzeptiere mehrere Schema-Varianten: decodierung/codes/findings + passage/quote, klartext/bedeutung/meaning, note/grade, code/section + const dec = obj.decodierung || obj.codes || obj.findings || []; + if (dec.length) { + html += '
      Code-Decodierung:'; + dec.forEach(d => { + const passage = d.passage || d.quote || d.text || ''; + const klartext = d.klartext || d.bedeutung || d.meaning || ''; + const code = d.code || d.section || ''; + const note = d.note || d.grade || ''; + const risk = d.risk || ''; + const noteColor = note !== '' ? gradeColor(note) : '#8b8a99'; + html += `
      "${esc(passage)}"
      `; + if (code) html += `
      ${esc(code)}
      `; + if (klartext) html += `
      ↳ ${esc(klartext)}
      `; + if (note !== '') html += `
      Note: ${esc(note)}${risk ? ` · Risiko: ${esc(risk)}` : ''}
      `; + html += '
      '; + }); + html += '
      '; + } + const redFlags = obj.red_flags || []; + if (redFlags.length) { + html += '
      🚩 Red Flags:
        '; + redFlags.forEach(f => { html += `
      • ${esc(f)}
      • `; }); + html += '
      '; + } + const missing = obj.missing_elements || []; + if (missing.length) { + html += '
      Fehlende Pflicht-Elemente:
        '; + missing.forEach(m => { html += `
      • ${esc(m)}
      • `; }); + html += '
      '; + } + const rewrites = obj.rewrite_suggestions || []; + if (rewrites.length) { + html += '
      Umschreib-Vorschläge:'; + rewrites.forEach(r => { + html += ``; + }); + html += '
      OriginalVorschlagWarum
      ${esc(r.original||'')}${esc(r.rewrite||'')}${esc(r.why||'')}
      '; + } + const dsources = obj.sources || obj.quellen || []; + if (dsources.length) { + html += '
      Quellen: ' + dsources.map(s => `${esc(s)}`).join(' · ') + '
      '; + } + } else if (obj.type === 'write') { + // Zita — Zeugnis-Schreiber + if (obj.role) html += `
      Rolle: ${esc(obj.role)}
      `; + if (obj.grade != null) { + const gColor = obj.grade <= 2 ? '#22c55e' : obj.grade <= 3 ? '#eab308' : '#dc2626'; + html += `
      Note: ${esc(obj.grade)}
      `; + } + const zeugnisText = obj.zeugnis || obj.zeugnis_text || obj.markdown || obj.text; + if (zeugnisText) { + html += '
      Zeugnis-Entwurf:'; + html += `
      ${esc(zeugnisText)}
      `; + html += '
      '; + } + const notenSignale = obj.noten_signale || []; + if (notenSignale.length) { + html += '
      Noten-Signale:'; + notenSignale.forEach(s => { + html += ``; + }); + html += '
      SatzCodiert
      ${esc(s.satz || '')}${esc(s.codiert || '')}
      '; + } + const paragraphen = obj.verwendete_paragraphen || obj.paragraphen || []; + if (paragraphen.length) { + html += '
      Verwendete Paragraphen: ' + paragraphen.map(p => `${esc(p)}`).join(' · ') + '
      '; + } + const warnings = obj.warnings || obj.warnungen || []; + if (warnings.length) { + html += '
      ⚠ Warnungen:
        '; + warnings.forEach(w => { html += `
      • ${esc(w)}
      • `; }); + html += '
      '; + } + const notes = obj.notes || obj.hinweise || []; + if (notes.length) { + html += '
      Hinweise:
        '; + notes.forEach(n => { html += `
      • ${esc(n)}
      • `; }); + html += '
      '; + } + const wsources = obj.sources || obj.quellen || []; + if (wsources.length) { + html += '
      Quellen: ' + wsources.map(s => `${esc(s)}`).join(' · ') + '
      '; + } + } else if (obj.type === 'calc') { + // LIBRA — Kalkulations-Rechner + if (obj.formel) html += `
      Formel: ${esc(obj.formel)}
      `; + if (obj.inputs && typeof obj.inputs === 'object') { + html += '
      Eingaben:'; + Object.entries(obj.inputs).forEach(([k,v]) => { html += ``; }); + html += '
      ${esc(k)}${esc(v)}
      '; + } + const steps = obj.schritte || obj.steps || []; + if (steps.length) { + html += '
      Rechenweg:
        '; + steps.forEach(s => { html += `
      1. ${esc(typeof s === 'string' ? s : (s.text || JSON.stringify(s)))}
      2. `; }); + html += '
      '; + } + if (obj.ergebnis != null) html += `
      Ergebnis: ${esc(obj.ergebnis)}
      `; + } else if (obj.type === 'unterweisung') { + // IDA — AdA-Unterweisung (4-Stufen / Lehrgespraech / Leittext) + if (obj.methode) html += `
      Methode: ${esc(obj.methode)}
      `; + const lz = obj.lernzielanalyse || obj.lernziele || null; + if (lz && typeof lz === 'object') { + html += '
      Lernzielanalyse:'; + if (lz.richtlernziel) html += ``; + if (lz.groblernziel) html += ``; + if (lz.feinlernziel) html += ``; + if (lz.bereich) html += ``; + html += '
      Richtlernziel${esc(lz.richtlernziel)}
      Groblernziel${esc(lz.groblernziel)}
      Feinlernziel${esc(lz.feinlernziel)}
      Bereich${esc(lz.bereich)}
      '; + } + const phasen = obj.phasen || obj.stufen || obj.phases || []; + if (phasen.length) { + let totalMin = 0; + phasen.forEach(p => { if (typeof p.minuten === 'number') totalMin += p.minuten; }); + html += `
      Phasen${totalMin?` (${totalMin} Min gesamt)`:''}:`; + phasen.forEach((p, i) => { + html += `
      ${i+1}. ${esc(p.name || p.stufe || '')}${p.minuten?` · ${esc(p.minuten)} Min`:''}
      `; + if (p.ausbilder_tut) html += `
      Ausbilder:in: ${esc(p.ausbilder_tut)}
      `; + if (p.azubi_tut) html += `
      Azubi: ${esc(p.azubi_tut)}
      `; + if (p.feedback_check) html += `
      Check: ${esc(p.feedback_check)}
      `; + html += '
      '; + }); + html += '
      '; + } + if (obj.erfolgskontrolle) { + html += `
      Erfolgskontrolle: ${esc(obj.erfolgskontrolle)}
      `; + } + const alt = obj.handlungsalternativen || obj.alternativen || []; + if (alt.length) { + html += '
      Handlungs-Alternativen:
        '; + alt.forEach(a => { html += `
      • ${esc(a)}
      • `; }); + html += '
      '; + } + } else if (obj.type === 'compliance_report') { + // Vera — Website-Compliance-Check (TMG/DSGVO/TTDSG) + const ampelColor = { green: '#10b981', yellow: '#f59e0b', red: '#ef4444' }[obj.ampel] || '#8b8a99'; + const ampelEmoji = { green: '🟢', yellow: '🟡', red: '🔴' }[obj.ampel] || '⚪'; + if (obj.url) html += `
      Geprüfte URL: ${esc(obj.url)}
      `; + if (typeof obj.overall_score === 'number') { + html += `
      ${ampelEmoji} Score: ${obj.overall_score} %
      `; + } + if (obj.summary) html += `
      ${esc(obj.summary)}
      `; + // Pro Section: Impressum, Datenschutz, Cookies + const sections = [ + { key: 'impressum', label: '📄 Impressum (TMG § 5 + § 18 MStV + ODR + VSBG)' }, + { key: 'datenschutz', label: '🛡 Datenschutzerklärung (DSGVO Art. 13)' }, + ]; + for (const s of sections) { + const sec = obj[s.key]; + if (!sec) continue; + const sScore = typeof sec.score === 'number' ? ` — ${sec.score} %` : ''; + const sUrl = sec.url ? ` ↗ Quelle` : ''; + html += `
      ${s.label}${sScore}${sUrl}`; + if (Array.isArray(sec.ok) && sec.ok.length) { + html += '
        '; + sec.ok.forEach(o => { html += `
      • ✓ ${esc(o)}
      • `; }); + html += '
      '; + } + if (Array.isArray(sec.missing) && sec.missing.length) { + html += '
        '; + sec.missing.forEach(m => { html += `
      • ✗ ${esc(m)}
      • `; }); + html += '
      '; + } + if (Array.isArray(sec.warnings) && sec.warnings.length) { + html += '
        '; + sec.warnings.forEach(w => { html += `
      • ⚠ ${esc(w)}
      • `; }); + html += '
      '; + } + html += '
      '; + } + // Cookies + const c = obj.cookies; + if (c && typeof c === 'object') { + const bannerEmoji = c.banner_present ? '✓' : '✗'; + const bannerColor = c.banner_present ? '#10b981' : '#ef4444'; + html += `
      🍪 Cookies (TTDSG § 25)`; + html += `
      ${bannerEmoji} Cookie-Banner: ${c.banner_present ? 'erkannt' : 'NICHT erkannt'}${c.banner_signal ? ` ${esc(c.banner_signal)}` : ''}
      `; + if (Array.isArray(c.third_party_loaders_pre_consent) && c.third_party_loaders_pre_consent.length) { + html += `
      ⚠ ${c.third_party_loaders_pre_consent.length} Drittanbieter im HTML — prüfen ob hinter Consent-Gate:
        `; + c.third_party_loaders_pre_consent.forEach(t => { html += `
      • ${esc(t)}
      • `; }); + html += '
      '; + } + if (Array.isArray(c.notes) && c.notes.length) { + html += '
        '; + c.notes.forEach(n => { html += `
      • ${esc(n)}
      • `; }); + html += '
      '; + } + html += '
      '; + } + // Next steps + if (Array.isArray(obj.next_steps) && obj.next_steps.length) { + html += '
      Nächste Schritte:
        '; + obj.next_steps.forEach(n => { html += `
      1. ${esc(n)}
      2. `; }); + html += '
      '; + } + if (obj.scanned_at) html += `
      Geprüft am ${esc(obj.scanned_at.replace('T',' ').slice(0,19))} UTC
      `; + } + const HINT_BY_TYPE = { + audit:'↳ Crosswalk gespeichert. Brauchst du Tech-Doku Anhang IV (VESTIGIA) oder die DPIA-Tiefe (Cora)?', + privacy_check:'↳ Bei Unsicherheit: PDL/EL informieren, Schweigepflicht §203 StGB hat Vorrang vor DSGVO.', + mail_check:'↳ Im Zweifel: nicht klicken, an IT/SOC weiterleiten. Eskalation > Schaden.', + plan:'↳ Tipp: 1. Tag-Erlebnis ist der wichtigste Baustein — ohne Wow-Moment bröckelt die Probezeit.', + validate:'↳ Bei "errors": Rechnung nicht buchen, Lieferanten kontaktieren, Validator-Output anhängen.', + interview:'↳ Story-First: lass die Person erzählen, hör 3 Sekunden nach der Antwort weiter zu.', + decode:'↳ Bei kritischen Codes: Anwält:in für Arbeitsrecht konsultieren, Zeugnis-Anfechtung prüfen.', + write:'↳ Vor Übergabe: 4-Augen-Review durch HR-Lead, Datum prüfen, korrekt unterschreiben.', + calc:'↳ Stichproben mit echten Aufträgen testen — Theorie-Marge ≠ Praxis-Marge.', + unterweisung:'↳ Methode folgt Lernziel: psychomotorisch → 4-Stufen, kognitiv → Lehrgespräch, komplex → Leittext.', + }; + const hint = HINT_BY_TYPE[obj.type] || '↳ Nutze den Quiz-, Karteikarten- oder Chat-Tab für die interaktive Version.'; + html += `
      ${hint}
      `; + html += '
      '; + return html; + } + + async function requestStructured(prompt) { + const data = await chatAPI(prompt, []); + let raw = _extractJSON(data.reply || ''); + try { return JSON.parse(raw); } catch (e1) { + try { return JSON.parse(_repairJSON(raw)); } catch (e2) { + const heal = await chatAPI(prompt + "\n\nHINWEIS: Dein letzter JSON-Output war invalid (Parse-Fehler). Antworte JETZT NUR mit sauberem JSON. Keine Kommentare, kein Markdown-Fence, keine trailing commas, alle String-Werte in doppelten Anf\u00fchrungszeichen.", []); + const healRaw = _extractJSON(heal.reply || ''); + try { return JSON.parse(healRaw); } catch (e3) { + try { return JSON.parse(_repairJSON(healRaw)); } catch (e4) { + const err = new Error('Konnte JSON nicht parsen \u2014 Modell liefert kaputtes Format. Versuch es noch mal.'); + err.original = e1.message; err.raw = raw.slice(0, 300); + throw err; + } + } + } + } + } + + // ==== Quiz ==== + const quizState = { set: null, idx: 0, correct: 0, topic: null }; + + function renderQuizIntro() { + const host = $('#quiz-host'); + const topics = CURRICULA.curricula.flatMap(c => + c.modules.map(m => ({ id: m.id, curr: c.title, title: m.title, color: c.color })) + ); + host.innerHTML = ` +
      +
      +

      🎯 Quiz-Thema wählen

      +

      Wähle ein Modul — Luna generiert 10 Multiple-Choice-Fragen.

      +
      +
      + + +
      + +
      +
      +
      + `; + const pillHost = $('#quiz-topic-pills'); + CURRICULA.curricula.forEach(c => { + c.modules.forEach(m => { + const btn = document.createElement('button'); + btn.className = 'topic-pill'; + btn.dataset.curr = c.id; + btn.dataset.mod = m.id; + btn.textContent = m.title; + btn.title = c.title; + btn.addEventListener('click', () => { + $$('#quiz-topic-pills .topic-pill').forEach(p => p.setAttribute('aria-selected', 'false')); + btn.setAttribute('aria-selected', 'true'); + quizState.topic = { curr: c, mod: m }; + }); + pillHost.appendChild(btn); + }); + }); + $('#quiz-start').addEventListener('click', () => { + if (!quizState.topic) { toast('Bitte ein Thema wählen.', 'warn'); return; } + const count = parseInt($('#quiz-count').value, 10); + startQuiz(quizState.topic, count); + }); + } + + async function startQuiz(topic, count) { + const host = $('#quiz-host'); + host.innerHTML = `

      Luna erstellt ${count} Fragen zu „${topic.mod.title}" …

      `; + try { + const topicText = `${topic.curr.title} — ${topic.mod.title}`; + const data = await requestStructured(`QUIZ_REQUEST topic="${topicText}" count=${count}`); + if (!data.questions || !Array.isArray(data.questions) || data.questions.length === 0) { + throw new Error('Keine Fragen erhalten'); + } + quizState.set = data.questions; + quizState.idx = 0; + quizState.correct = 0; + renderQuizQuestion(); + } catch (e) { + host.innerHTML = `

      ⚠ Konnte Quiz nicht erstellen: ${e.message}

      `; + toast('Quiz-Generierung fehlgeschlagen', 'error'); + } + } + + function renderQuizQuestion() { + const host = $('#quiz-host'); + const q = quizState.set[quizState.idx]; + if (!q) return renderQuizDone(); + const letters = ['A', 'B', 'C', 'D', 'E', 'F']; + host.innerHTML = ` +
      +
      + Frage ${quizState.idx + 1} / ${quizState.set.length} + ✓ ${quizState.correct} +
      +
      ${escapeHTML(q.q)}
      +
      + ${q.options.map((opt, i) => ` + + `).join('')} +
      + + +
      + `; + $$('#quiz-opts .quiz-option').forEach(btn => { + btn.addEventListener('click', () => handleQuizAnswer(btn, q)); + }); + } + + function handleQuizAnswer(btn, q) { + const chosen = parseInt(btn.dataset.idx, 10); + const correct = q.correct; + state.totalAnswers += 1; + const curId = quizState.topic.curr.id; + const modId = quizState.topic.mod.id; + if (!state.mastery[curId]) state.mastery[curId] = { correct: 0, total: 0 }; + state.mastery[curId].total += 1; + state.moduleTotal[modId] = (state.moduleTotal[modId] || 0) + 1; + + $$('#quiz-opts .quiz-option').forEach((b, i) => { + b.disabled = true; + if (i === correct) b.classList.add('correct'); + else if (i === chosen) b.classList.add('wrong'); + }); + const ex = $('#quiz-explain'); + ex.classList.remove('hidden'); + if (chosen === correct) { + quizState.correct += 1; + state.correctAnswers += 1; + state.quizStreak += 1; + if (state.quizStreak > state.maxQuizStreak) state.maxQuizStreak = state.quizStreak; + state.mastery[curId].correct += 1; + state.moduleCorrect[modId] = (state.moduleCorrect[modId] || 0) + 1; + addXP(10, 'Richtig!'); + ex.innerHTML = `✓ Richtig!
      ${escapeHTML(q.explain || '')} +
      + + + + +
      + `; + $$('#quiz-explain .deepdive-btn').forEach(b => b.addEventListener('click', () => quizDeepdive(b.dataset.kind, q, chosen, correct))); + } else { + state.quizStreak = 0; + ex.innerHTML = `✗ Falsch. Richtig wäre ${['A','B','C','D','E','F'][correct]}. ${escapeHTML(q.explain || '')} +
      + + + + +
      + `; + $$('#quiz-explain .deepdive-btn').forEach(b => b.addEventListener('click', () => quizDeepdive(b.dataset.kind, q, chosen, correct))); + } + saveState(); + touchActivity(); + checkBadges(); + $('#quiz-next').classList.remove('hidden'); + $('#quiz-next-btn').addEventListener('click', () => { + quizState.idx += 1; + if (quizState.idx >= quizState.set.length) renderQuizDone(); + else renderQuizQuestion(); + }); + } + + function renderQuizDone() { + const host = $('#quiz-host'); + const pct = Math.round(quizState.correct / quizState.set.length * 100); + state.completedQuizzes += 1; + // Module complete (>=80% correct in this quiz) — bonus XP + track for DSGVO-Master + if (pct >= 80 && quizState.topic) { + const modId = quizState.topic.mod.id; + if (!state.completedCurricula.includes(modId)) { + state.completedCurricula.push(modId); + addXP(20, 'Modul abgeschlossen: ' + quizState.topic.mod.title); + } + // Lernreise: Quiz mit ≥80% beendet → Step kann auto-completed werden. + // Portal-Listener entscheidet, ob er das übernimmt. + sendJourneyBeacon('quiz_passed', { module: modId, score_pct: pct }); + } + saveState(); + checkBadges(); + host.innerHTML = ` +
      +

      Quiz beendet!

      +
      ${quizState.correct} / ${quizState.set.length}
      +

      ${pct}% richtig — ${pct >= 80 ? 'Ausgezeichnet!' : pct >= 60 ? 'Solide!' : 'Probier es noch mal.'}

      +
      + + +
      +
      + `; + $('#quiz-again').addEventListener('click', () => startQuiz(quizState.topic, quizState.set.length)); + $('#quiz-new').addEventListener('click', () => renderQuizIntro()); + } + + // ==== Flashcards (SM-2 Spaced Repetition) ==== + const flashState = { topic: null, deck: null, cur: null, showBack: false }; + + function renderFlashIntro() { + const host = $('#flash-host'); + host.innerHTML = ` +
      +
      +

      🃏 Flashcards

      +

      Luna erstellt Karteikarten zu einem Thema. Bewerte dein Erinnerungsvermögen — das System wiederholt schwere Karten öfter (SM-2).

      +
      +
      + + +
      + + +
      +
      +
      +
      + `; + const pillHost = $('#flash-topic-pills'); + CURRICULA.curricula.forEach(c => { + c.modules.forEach(m => { + const btn = document.createElement('button'); + btn.className = 'topic-pill'; + btn.textContent = m.title; + btn.title = c.title; + btn.addEventListener('click', () => { + $$('#flash-topic-pills .topic-pill').forEach(p => p.setAttribute('aria-selected', 'false')); + btn.setAttribute('aria-selected', 'true'); + flashState.topic = { curr: c, mod: m }; + updateFlashStats(); + }); + pillHost.appendChild(btn); + }); + }); + $('#flash-start').addEventListener('click', () => { + if (!flashState.topic) { toast('Bitte ein Thema wählen.', 'warn'); return; } + loadNewFlashCards(flashState.topic, parseInt($('#flash-count').value, 10)); + }); + $('#flash-review').addEventListener('click', () => { + if (!flashState.topic) { toast('Bitte ein Thema wählen.', 'warn'); return; } + startReview(flashState.topic); + }); + updateFlashStats(); + } + + function updateFlashStats() { + const el = $('#flash-stats'); + if (!el) return; + if (!flashState.topic) { el.textContent = ''; return; } + const tid = flashState.topic.mod.id; + const cards = flashCards[tid] || []; + const due = cards.filter(c => !c.due || c.due <= now()).length; + el.textContent = cards.length === 0 + ? 'Noch keine Karten in diesem Thema.' + : `${cards.length} Karten gespeichert · ${due} fällig heute`; + } + + async function loadNewFlashCards(topic, count) { + const host = $('#flash-host'); + host.innerHTML = `

      Luna erstellt Karten zu „${topic.mod.title}" …

      `; + try { + const topicText = `${topic.curr.title} — ${topic.mod.title}`; + const data = await requestStructured(`FLASHCARD_REQUEST topic="${topicText}" count=${count}`); + if (!data.cards || !data.cards.length) throw new Error('Keine Karten erhalten'); + const tid = topic.mod.id; + if (!flashCards[tid]) flashCards[tid] = []; + for (const c of data.cards) { + flashCards[tid].push({ + front: c.front, back: c.back, hint: c.hint || '', + ef: 2.5, interval: 0, reps: 0, due: now() + }); + } + saveFlashCards(); + startReview(topic); + } catch (e) { + host.innerHTML = `

      ⚠ Fehler: ${e.message}

      `; + toast('Karten-Generierung fehlgeschlagen', 'error'); + } + } + + function startReview(topic) { + flashState.topic = topic; + const tid = topic.mod.id; + flashState.deck = (flashCards[tid] || []).filter(c => !c.due || c.due <= now()); + if (flashState.deck.length === 0) { + const host = $('#flash-host'); + host.innerHTML = `

      Keine fälligen Karten in „${escapeHTML(topic.mod.title)}"

      Lege neue Karten an oder komm später wieder.

      `; + $('#back-to-flash').addEventListener('click', renderFlashIntro); + return; + } + flashState.cur = 0; + flashState.showBack = false; + renderFlashCard(); + } + + function renderFlashCard() { + const host = $('#flash-host'); + const card = flashState.deck[flashState.cur]; + if (!card) { + host.innerHTML = `

      Review beendet 🎉

      Alle fälligen Karten in „${escapeHTML(flashState.topic.mod.title)}" durch.

      `; + $('#back-to-flash').addEventListener('click', renderFlashIntro); + return; + } + host.innerHTML = ` +
      + Karte ${flashState.cur + 1} / ${flashState.deck.length} + ${escapeHTML(flashState.topic.mod.title)} +
      +
      + ${flashState.showBack + ? `
      ${escapeHTML(card.front)}${escapeHTML(card.back)}
      ` + : `
      ${escapeHTML(card.front)}
      ${card.hint ? `
      Hinweis: ${escapeHTML(card.hint)}
      ` : ''}
      Klicken oder Leertaste drücken zum Umdrehen
      `} +
      + ${flashState.showBack ? ` +
      + + + + +
      +
      + + + + +
      + + ` : ''} + `; + $('#flash-card').addEventListener('click', flipCard); + $('#flash-card').addEventListener('keydown', (ev) => { + if (ev.key === ' ' || ev.key === 'Enter') { ev.preventDefault(); flipCard(); } + }); + $('#flash-card').focus(); + $$('.flash-btn').forEach(btn => btn.addEventListener('click', () => rateCard(parseInt(btn.dataset.rating, 10)))); + $$('.deepdive-btn').forEach(btn => btn.addEventListener('click', () => cardDeepdive(btn.dataset.kind, card))); + } + async function cardDeepdive(kind, card) { + const prompts = { + more: `Zu dieser Lernkarte — gib mir 2-3 vertiefende Informationen, die über die Karte hinausgehen. Keine Wiederholung.\n\nFrage: ${card.front}\nAntwort: ${card.back}${card.hint ? '\nHinweis: ' + card.hint : ''}\n\nMarkdown erlaubt. Kurz halten (≤180 Wörter).`, + sources: `Für diese Lernkarte — wo kann der Lerner das vertiefen? Nenne 3-5 konkrete öffentliche Quellen (Gesetzestext + Paragraph, Norm-Nummer, Fachbuch mit Autor, Paper, offizielle Webseite, IHK-Leitfaden). Je Quelle EINE Zeile. Keine Allgemeinplätze wie „Fachliteratur".\n\nFrage: ${card.front}\nAntwort: ${card.back}\n\nFormat: Markdown-Liste.`, + example: `Praxis-Beispiel zu dieser Lernkarte — ein konkretes Szenario aus dem Berufsalltag in 3-5 Sätzen, kein Theorie-Kram.\n\nFrage: ${card.front}\nAntwort: ${card.back}`, + ask: null, + }; + const host = $('#flash-deepdive'); + host.classList.remove('hidden'); + if (kind === 'ask') { + const q = window.prompt('Deine Frage zu dieser Karte:', ''); + if (!q || !q.trim()) { host.classList.add('hidden'); return; } + host.innerHTML = `
      denkt …
      `; + try { + const data = await chatAPI(`Zu dieser Lernkarte:\nFrage: ${card.front}\nAntwort: ${card.back}\n\nMeine Zusatzfrage: ${q.trim()}\n\nBeantworte präzise, Markdown erlaubt, ≤200 Wörter.`, []); + host.innerHTML = '
      ' + renderMD(data.reply || '') + '
      '; + } catch (e) { host.innerHTML = `
      Fehler: ${e.message || 'unbekannt'}
      `; } + } else { + host.innerHTML = `
      ${kind === 'sources' ? 'sucht Quellen' : kind === 'example' ? 'baut Beispiel' : 'vertieft'} …
      `; + try { + const data = await chatAPI(prompts[kind], []); + host.innerHTML = '
      ' + renderMD(data.reply || '') + '
      '; + } catch (e) { host.innerHTML = `
      Fehler: ${e.message || 'unbekannt'}
      `; } + } + const cb = $('#flash-deepdive .close-dd'); + if (cb) cb.addEventListener('click', () => host.classList.add('hidden')); + } + async function quizDeepdive(kind, q, chosenIdx, correctIdx) { + const host = $('#quiz-deepdive'); + host.classList.remove('hidden'); + const chosenTxt = q.options[chosenIdx], correctTxt = q.options[correctIdx]; + const prompts = { + more: `Gib mir zu dieser Quiz-Frage 2-3 weiterführende Infos, die über die knappe Erklärung hinausgehen. Kein Wiederholen.\n\nFrage: ${q.q}\nRichtig: ${correctTxt}\nErklärung: ${q.explain || '(—)'}\n\nMarkdown erlaubt, ≤180 Wörter.`, + sources: `Für diese Quiz-Frage — wo kann der Lerner das vertiefen? 3-5 konkrete öffentliche Quellen (Gesetzestext+Paragraph, Norm-Nummer, Fachbuch mit Autor, Paper, offizielle Webseite). Je Quelle EINE Zeile, keine Allgemeinplätze.\n\nFrage: ${q.q}\nRichtig: ${correctTxt}\n\nMarkdown-Liste.`, + why: chosenIdx === correctIdx + ? `Ich hab richtig geantwortet — erkläre mir NOCH tiefer warum das die richtige Wahl ist. Was hätte falsch gelegen bei den anderen Optionen? Je Option 1 Satz.\n\nFrage: ${q.q}\nOptionen: ${q.options.map((o,i)=>(i+1)+'. '+o).join(' | ')}\nRichtig: ${correctTxt}` + : `Ich hab „${chosenTxt}" gewählt, richtig wäre „${correctTxt}". Erkläre mir präzise wo mein Denkfehler lag und wie ich das beim nächsten Mal anders angehe. Sokratisch wenn möglich.\n\nFrage: ${q.q}\nMeine Antwort: ${chosenTxt}\nRichtig: ${correctTxt}`, + ask: null, + }; + if (kind === 'ask') { + const userQ = window.prompt('Deine Frage zu dieser Quiz-Frage:', ''); + if (!userQ || !userQ.trim()) { host.classList.add('hidden'); return; } + host.innerHTML = `
      denkt …
      `; + try { + const data = await chatAPI(`Zur Quiz-Frage: "${q.q}"\nRichtige Antwort: ${correctTxt}\nMeine Antwort: ${chosenTxt}\n\nZusatzfrage: ${userQ.trim()}\n\nBeantworte präzise, Markdown, ≤200 Wörter.`, []); + host.innerHTML = '
      ' + renderMD(data.reply || '') + '
      '; + } catch (e) { host.innerHTML = `
      Fehler: ${e.message || 'unbekannt'}
      `; } + } else { + host.innerHTML = `
      ${kind === 'sources' ? 'sucht Quellen' : kind === 'why' ? 'analysiert' : 'vertieft'} …
      `; + try { + const data = await chatAPI(prompts[kind], []); + host.innerHTML = '
      ' + renderMD(data.reply || '') + '
      '; + } catch (e) { host.innerHTML = `
      Fehler: ${e.message || 'unbekannt'}
      `; } + } + const cb = $('#quiz-deepdive .close-dd'); + if (cb) cb.addEventListener('click', () => host.classList.add('hidden')); + } + function flipCard() { + if (flashState.showBack) return; + const el = $('#flash-card'); + if (el) { el.classList.add('flipping'); setTimeout(() => { flashState.showBack = true; renderFlashCard(); }, 120); } + } + function rateCard(rating) { + const card = flashState.deck[flashState.cur]; + // SM-2 algorithm + const q = rating; // 0..3 (we map: 0→ again, 1→hard, 2→good, 3→easy) + const qMap = [0, 3, 4, 5]; // SM-2 uses 0..5, we map our 0..3 to subset + const sm2q = qMap[q]; + if (sm2q < 3) { + card.reps = 0; + card.interval = 0; + card.due = now() + 60 * 1000; // 1 min + } else { + card.reps += 1; + if (card.reps === 1) card.interval = 1; + else if (card.reps === 2) card.interval = 3; + else card.interval = Math.round(card.interval * card.ef); + card.due = now() + card.interval * 86400 * 1000; + } + card.ef = Math.max(1.3, card.ef + (0.1 - (5 - sm2q) * (0.08 + (5 - sm2q) * 0.02))); + saveFlashCards(); + + // XP + state.flashCardsRated += 1; + state.totalAnswers += 1; + if (q === 2) { addXP(5, 'Flashcard Gut'); state.correctAnswers += 1; } + else if (q === 3) { addXP(2, 'Flashcard Leicht'); state.correctAnswers += 1; } + + // Track per-run ratings for modulePassedFlash (all cards in deck rated >= Gut) + flashState.runRatings = flashState.runRatings || []; + flashState.runRatings.push(q); + + saveState(); + touchActivity(); + checkBadges(); + + flashState.showBack = false; + flashState.cur += 1; + + // If end of deck and topic set, check if all cards rated Gut/Leicht + if (flashState.cur >= flashState.deck.length && flashState.topic) { + const allGood = flashState.runRatings.length >= 3 && flashState.runRatings.every(r => r >= 2); + if (allGood) { + const modId = flashState.topic.mod.id; + state.modulePassedFlash[modId] = true; + saveState(); + checkBadges(); + } + flashState.runRatings = []; + } + + renderFlashCard(); + } + + // ==== Progress ==== + function renderProgress() { + const host = $('#progress-host'); + const li = levelInfo(); + const masteryRows = Object.entries(state.mastery) + .map(([cid, m]) => { + const c = CURRICULA.curricula.find(x => x.id === cid); + if (!c || m.total === 0) return null; + const pct = Math.round(m.correct / m.total * 100); + return { title: c.title, color: c.color, pct, correct: m.correct, total: m.total }; + }) + .filter(Boolean) + .sort((a, b) => b.pct - a.pct); + + const badges = (CURRICULA.badges || []); + host.innerHTML = ` +
      +
      +
      +
      ${state.xp}
      +
      XP gesamt
      +
      +
      +
      Lvl ${li.levelNum} · ${li.title}
      +
      ${li.next ? li.next.min - state.xp + ' XP bis ' + li.next.title : 'Top-Level erreicht'}
      +
      +
      +
      🔥 ${state.currentStreak}
      +
      Tage-Streak (max. ${state.maxStreak})
      +
      +
      +
      +
      +
      ${state.totalAnswers}
      +
      Antworten gesamt
      +
      +
      +
      ${state.totalAnswers === 0 ? '0%' : Math.round(state.correctAnswers / state.totalAnswers * 100) + '%'}
      +
      Trefferquote
      +
      +
      +
      ${state.completedQuizzes}
      +
      Quizze
      +
      +
      + + ${li.next ? ` +
      +

      Fortschritt zu „${li.next.title}"

      +
      +
      ${Math.round(li.pct)}% · ${state.xp} / ${li.next.min} XP
      +
      ` : ''} + +
      +

      Mastery pro Curriculum

      + ${masteryRows.length === 0 + ? '

      Noch keine Daten. Mach ein Quiz, um Mastery aufzubauen.

      ' + : '
      ' + masteryRows.map(r => ` +
      +
      + ${escapeHTML(r.title)} + ${r.pct}% (${r.correct}/${r.total}) +
      +
      +
      + `).join('') + '
      ' + } +
      + +
      +

      Abzeichen (${Object.keys(state.badges).length}/${badges.length})

      +
      + ${badges.map(b => { + const earned = !!state.badges[b.id]; + const icons = { award:'🏆', flame:'🔥', star:'⭐', calendar:'🗓', crown:'👑', moon:'🌙', sun:'☀️', shield:'🛡️', detective:'🕵️', clock:'⏱️', handshake:'🤝', medal:'🎖️', diamond:'💎' }; + return `
      + ${icons[b.icon] || '🎖'} +
      ${escapeHTML(b.title)}
      +
      ${escapeHTML(b.description)}
      +
      `; + }).join('')} +
      +
      + +
      +

      Daten zurücksetzen

      +

      Lokal gespeichert (kein Server-Tracking).

      + +
      +
      + `; + $('#reset-data').addEventListener('click', () => { + if (confirm('Alle lokalen Daten (XP, Streaks, Flashcards, Chat) wirklich löschen?')) { + localStorage.removeItem(LS_KEY); + localStorage.removeItem(LS_CHAT); + localStorage.removeItem(LS_FLASH); + state = loadState(); + chatHistory = []; + flashCards = {}; + restoreChat(); + renderProgress(); + toast('Daten zurückgesetzt.', 'success'); + } + }); + } + + // ==== Curriculum tree ==== + function renderCurriculum() { + const host = $('#curr-host'); + host.innerHTML = '
      '; + const root = $('#curr-tree-root'); + CURRICULA.curricula.forEach(c => { + const rootEl = document.createElement('details'); + rootEl.className = 'curr-root'; + rootEl.innerHTML = ` + + ${c.title.charAt(0)} + + ${escapeHTML(c.title)} + ${escapeHTML(c.short)} · ${c.modules.length} Module + + + +
      + ${c.modules.map(m => ` +
      + ${escapeHTML(m.title)} + +
      + `).join('')} +
      + `; + root.appendChild(rootEl); + }); + $$('.curr-mod').forEach(el => el.addEventListener('click', () => { + const c = CURRICULA.curricula.find(x => x.id === el.dataset.curr); + const m = c.modules.find(x => x.id === el.dataset.mod); + renderModuleDetail(c, m); + })); + } + + function renderModuleDetail(c, m) { + const host = $('#curr-host'); + host.innerHTML = ` +
      + +

      ${escapeHTML(m.title)}

      +

      Lernziele

      +
        ${m.objectives.map(o => `
      • ${escapeHTML(o)}
      • `).join('')}
      +

      Kernthemen

      +
        ${(m.topics || []).map(t => `
      • ${escapeHTML(t)}
      • `).join('')}
      + ${m.hours ? `

      Umfang: ~${m.hours} h

      ` : ''} +
      + + + +
      +
      + `; + $('#breadcrumb-back').addEventListener('click', renderCurriculum); + $$('.mod-actions button').forEach(b => b.addEventListener('click', () => { + const act = b.dataset.action; + if (act === 'quiz') { + quizState.topic = { curr: c, mod: m }; + switchMode('quiz'); + setTimeout(() => startQuiz({ curr: c, mod: m }, 10), 50); + } else if (act === 'flash') { + flashState.topic = { curr: c, mod: m }; + switchMode('flash'); + setTimeout(() => { + const tid = m.id; + if (flashCards[tid] && flashCards[tid].length) startReview({ curr: c, mod: m }); + else loadNewFlashCards({ curr: c, mod: m }, 10); + }, 50); + } else if (act === 'ask') { + switchMode('chat'); + $('#composer').value = `Erklär mir kurz und klar: ${m.title} (aus ${c.title}). Fokus auf ${m.topics.slice(0,3).join(', ')}.`; + $('#composer').focus(); + } + })); + } + + // ==== Mode switching ==== + function switchMode(mode) { + $$('.tab').forEach(t => { + const isActive = t.dataset.mode === mode; + t.setAttribute('aria-selected', isActive ? 'true' : 'false'); + }); + $$('.view').forEach(v => { + v.dataset.active = (v.id === 'view-' + mode) ? 'true' : 'false'; + }); + // The composer is shown only in chat mode + $('#composer-form').classList.toggle('hidden', mode !== 'chat'); + + if (mode === 'quiz') renderQuizIntro(); + if (mode === 'flash') renderFlashIntro(); + if (mode === 'progress') renderProgress(); + if (mode === 'curriculum') renderCurriculum(); + if (mode === 'chat') setTimeout(() => $('#composer').focus(), 50); + } + + // ==== Escape HTML ==== + function escapeHTML(s) { + return String(s).replace(/[&<>"']/g, ch => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + }[ch])); + } + + // ==== Boot ==== + // Lernreise-State (URL-Params), einmal beim Boot ausgelesen. + // Wird von renderJourneyBanner() für die UI und von sendJourneyBeacon() + // für die postMessage-Kommunikation zurück zum Portal-Tab benutzt. + const JOURNEY = (() => { + try { + const p = new URLSearchParams(window.location.search); + const slug = p.get('journey'); + if (!slug) return null; + const step = parseInt(p.get('step') || '0', 10); + const total = parseInt(p.get('total') || '0', 10); + let returnOrigin = ''; + try { + if (p.get('return')) returnOrigin = new URL(p.get('return')).origin; + } catch {} + return { slug, step, total, returnOrigin }; + } catch { return null; } + })(); + + // Beacon zurück an den Portal-Tab. Funktioniert nur wenn das Widget über + // `target="_blank" rel="opener"` aus der Lernreise-Seite geöffnet wurde + // (window.opener erreichbar). Bei direktem Aufruf der Bot-URL no-op. + function sendJourneyBeacon(event, extra = {}) { + if (!JOURNEY || !window.opener) return; + if (!JOURNEY.returnOrigin) return; + try { + window.opener.postMessage({ + type: 'qognio:journey-step-event', + journey: JOURNEY.slug, + step: JOURNEY.step, + event, // 'quiz_passed' | 'curriculum_completed' | 'manual' + ...extra, + }, JOURNEY.returnOrigin); + } catch (e) { + console.warn('journey beacon failed', e); + } + } + + // Lernreise-Banner: gerendert wenn der Bot via Portal-Lernreise geöffnet wurde + // (`?journey=&step=&total=&return=`). Nicht-invasiv, weglinkbar. + function renderJourneyBanner() { + try { + const params = new URLSearchParams(window.location.search); + const journey = params.get('journey'); + const step = parseInt(params.get('step') || '0', 10); + const total = parseInt(params.get('total') || '0', 10); + const ret = params.get('return') || ''; + if (!journey || step <= 0) return; + // Dismissed for this journey/step combo? (LS sticky) + const dismissKey = 'qognio.journey-banner.dismiss.v1'; + const dismissed = JSON.parse(localStorage.getItem(dismissKey) || '{}'); + const dKey = journey + ':' + step; + if (dismissed[dKey]) return; + const bar = document.createElement('div'); + bar.id = 'qognio-journey-banner'; + bar.setAttribute('role', 'note'); + bar.style.cssText = [ + 'position:sticky', 'top:0', 'z-index:100', + 'display:flex', 'align-items:center', 'gap:.75rem', + 'padding:.55rem .9rem', + 'background:linear-gradient(90deg, rgba(124,58,237,.18), rgba(34,211,238,.10))', + 'border-bottom:1px solid rgba(124,58,237,.35)', + 'color:var(--text, #e5e7eb)', 'font-size:.875rem', + 'box-shadow:0 1px 0 rgba(0,0,0,.05)', + ].join(';'); + const human = journey.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + bar.innerHTML = [ + '', + 'Lernreise: ' + human + '', + 'Schritt ' + step + (total > 0 ? ' von ' + total : '') + '', + '', + ret ? '↩ Zurück zur Reise' : '', + '', + ].join(''); + document.body.prepend(bar); + const btn = bar.querySelector('#qognio-journey-dismiss'); + if (btn) btn.addEventListener('click', () => { + dismissed[dKey] = Date.now(); + localStorage.setItem(dismissKey, JSON.stringify(dismissed)); + bar.remove(); + }); + } catch (e) { + console.warn('journey banner failed', e); + } + } + + async function boot() { + renderJourneyBanner(); + try { + const r = await fetch('curricula.json?v=2026-04-21'); + if (!r.ok) throw new Error('curricula.json HTTP ' + r.status); + CURRICULA = await r.json(); + } catch (e) { + toast('Curriculum konnte nicht geladen werden.', 'error', 6000); + console.error(e); + return; + } + restoreChat(); + touchActivity(); + + // Tabs + $$('.tab').forEach(tab => tab.addEventListener('click', () => switchMode(tab.dataset.mode))); + + // Welcome-card shortcuts + $$('[data-goto]').forEach(b => b.addEventListener('click', () => { + state.seenWelcome = true; saveState(); + switchMode(b.dataset.goto); + })); + + // Welcome-card prompt-fillers (special *_REQUEST modes) + $$('[data-prompt]').forEach(b => b.addEventListener('click', () => { + state.seenWelcome = true; saveState(); + switchMode('chat'); + const composer = $('#composer'); + if (composer) { + composer.value = b.dataset.prompt; + autogrow(composer); + setTimeout(() => composer.focus(), 50); + } + })); + + // Composer + const form = $('#composer-form'); + const ta = $('#composer'); + form.addEventListener('submit', (ev) => { + ev.preventDefault(); + const text = ta.value.trim(); + if (!text && pendingAttachments.length === 0) return; + ta.value = ''; + autogrow(ta); + sendChat(text); + }); + ta.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter' && !ev.shiftKey) { + ev.preventDefault(); + form.requestSubmit(); + } + }); + + // Attachments + const fileInput = $('#composer-file'); + const attachBtn = $('#composer-attach'); + if (attachBtn && fileInput) { + attachBtn.addEventListener('click', () => fileInput.click()); + fileInput.addEventListener('change', async (ev) => { + await addFiles(ev.target.files); + ev.target.value = ''; + }); + } + // Drag & Drop on composer + ['dragenter', 'dragover'].forEach(evt => form.addEventListener(evt, (ev) => { + if (ev.dataTransfer && Array.from(ev.dataTransfer.types || []).includes('Files')) { + ev.preventDefault(); + form.classList.add('dragover'); + } + })); + ['dragleave', 'drop'].forEach(evt => form.addEventListener(evt, (ev) => { + if (evt === 'dragleave' && ev.target !== form) return; + form.classList.remove('dragover'); + })); + form.addEventListener('drop', async (ev) => { + if (ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files.length) { + ev.preventDefault(); + await addFiles(ev.dataTransfer.files); + } + }); + // Paste image / file + ta.addEventListener('paste', async (ev) => { + const items = ev.clipboardData && ev.clipboardData.files; + if (items && items.length) { + ev.preventDefault(); + await addFiles(items); + } + }); + ta.addEventListener('input', () => autogrow(ta)); + function autogrow(el) { + el.style.height = 'auto'; + el.style.height = Math.min(el.scrollHeight, 140) + 'px'; + } + + // Keyboard shortcuts Ctrl+1..5 + document.addEventListener('keydown', (ev) => { + if (!(ev.ctrlKey || ev.metaKey)) return; + const map = { '1': 'chat', '2': 'quiz', '3': 'flash', '4': 'progress', '5': 'curriculum' }; + if (map[ev.key]) { ev.preventDefault(); switchMode(map[ev.key]); } + }); + + // Default focus + ta.focus(); + // Update status once per min (visual cue) + setInterval(() => { /* placeholder for future heartbeat */ }, 60000); + + console.log('Luna v2026-04-21 ready. XP:', state.xp, 'Streak:', state.currentStreak); + } + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); + else boot(); +})(); diff --git a/www/cockpit/cockpit.css b/www/cockpit/cockpit.css new file mode 100644 index 0000000..fce4a9d --- /dev/null +++ b/www/cockpit/cockpit.css @@ -0,0 +1,650 @@ +/* Luna Cockpit v3 — purple #a855f7, dark, no external deps */ + +:root { + --bg: #0a0a0f; + --bg-2: #11111b; + --bg-3: #1a1a26; + --bg-4: #232336; + --line: #2a2a3e; + --text: #e8e8f0; + --text-muted: #9090a8; + --text-dim: #6a6a85; + --accent: #a855f7; + --accent-2: #c084fc; + --accent-dark: #7c3aed; + --accent-rgb: 168, 85, 247; + --success: #10b981; + --warn: #f59e0b; + --danger: #ef4444; + --info: #38bdf8; + --shadow-sm: 0 1px 2px rgba(0,0,0,0.2); + --shadow-md: 0 4px 12px rgba(0,0,0,0.3); + --shadow-lg: 0 12px 32px rgba(0,0,0,0.5); + --radius: 8px; + --radius-lg: 12px; +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Inter", "Helvetica Neue", Arial, sans-serif; + font-size: 15px; + line-height: 1.5; + min-height: 100vh; +} +a { color: var(--accent-2); text-decoration: none; } +a:hover { color: var(--accent); } +button { font: inherit; cursor: pointer; border: none; background: transparent; color: inherit; } + +.cockpit { + max-width: 1280px; + margin: 0 auto; + padding: 1.5rem 1rem 6rem; + min-height: 100vh; +} + +/* ─── Topbar ────────────────────────────────────────────────────── */ +.topbar { display: flex; align-items: center; gap: 1rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--line); margin-bottom: 1.5rem; } +.brand { display: flex; align-items: center; gap: 0.75rem; } +.brand-icon { + width: 40px; height: 40px; + background: linear-gradient(135deg, var(--accent), var(--accent-dark)); + color: white; font-weight: 700; font-size: 22px; + border-radius: var(--radius); + display: grid; place-items: center; + box-shadow: var(--shadow-md); +} +.brand-text { display: flex; flex-direction: column; } +.brand-title { font-size: 1.2rem; font-weight: 600; } +.brand-title small { color: var(--text-muted); font-weight: 400; margin-left: 0.25rem; } +.brand-sub { font-size: 0.8rem; color: var(--text-muted); } +.spacer { flex: 1; } +.auth-state { display: flex; align-items: center; gap: 0.75rem; } +.auth-user { color: var(--text-muted); font-size: 0.85rem; } + +/* ─── Status block ─────────────────────────────────────────────── */ +.status-block { + background: linear-gradient(135deg, rgba(var(--accent-rgb),0.08), rgba(var(--accent-rgb),0.02)); + border: 1px solid rgba(var(--accent-rgb), 0.25); + border-radius: var(--radius-lg); + padding: 1rem 1.25rem; + margin-bottom: 1.5rem; +} +.status-title { font-size: 0.85rem; color: var(--accent-2); font-weight: 600; margin: 0 0 0.5rem; text-transform: uppercase; letter-spacing: 0.04em; } +.status-summary { font-size: 0.95rem; color: var(--text); } +.status-summary .dim { color: var(--text-muted); } + +/* ─── Top-level Space-Tabs ─────────────────────────────────────── */ +.space-tabs { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.5rem; + background: var(--bg-2); + padding: 0.5rem; + border-radius: var(--radius-lg); + border: 1px solid var(--line); + margin-bottom: 1.5rem; +} +.space-tab { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.85rem; + border-radius: var(--radius); + color: var(--text-muted); + transition: all 0.15s; + font-size: 0.95rem; + font-weight: 500; +} +.space-tab[aria-selected="true"] { + background: linear-gradient(135deg, rgba(var(--accent-rgb),0.18), rgba(var(--accent-rgb),0.05)); + color: var(--accent-2); + box-shadow: inset 0 0 0 1px rgba(var(--accent-rgb), 0.4); +} +.space-tab:hover { background: var(--bg-3); color: var(--text); } +.space-tab[aria-selected="true"]:hover { color: var(--accent-2); } +.space-icon { font-size: 1.1rem; } + +/* Each space-section is hidden unless data-active=true */ +.space { display: none; } +.space[data-active="true"] { display: block; } +.space-intro { margin-bottom: 1.25rem; } +.space-intro h2 { margin: 0 0 0.4rem; font-size: 1.15rem; } +.space-intro p { margin: 0; color: var(--text-muted); font-size: 0.9rem; } + +/* ─── Folder tabs (Dokumente) ──────────────────────────────────── */ +.folder-tabs { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.5rem; + background: var(--bg-2); + padding: 0.5rem; + border-radius: var(--radius-lg); + border: 1px solid var(--line); + margin-bottom: 1rem; +} +.folder-tab { + display: flex; flex-direction: column; align-items: center; gap: 0.25rem; + padding: 0.75rem; border-radius: var(--radius); + color: var(--text-muted); + transition: background 0.15s, color 0.15s; +} +.folder-tab[aria-selected="true"] { + background: rgba(var(--accent-rgb), 0.12); + color: var(--text); + box-shadow: inset 0 -2px 0 var(--accent); +} +.folder-tab:hover { background: var(--bg-3); color: var(--text); } +.folder-icon { font-size: 1.25rem; } +.folder-label { font-size: 0.85rem; font-weight: 500; } +.folder-count { + font-size: 0.7rem; background: var(--bg-3); color: var(--text-muted); + padding: 0.1rem 0.5rem; border-radius: 10px; min-width: 24px; text-align: center; +} +.folder-tab[aria-selected="true"] .folder-count { + background: rgba(var(--accent-rgb), 0.25); + color: var(--accent-2); +} + +.folder-body { + background: var(--bg-2); border: 1px solid var(--line); + border-radius: var(--radius-lg); padding: 1.25rem; +} +.folder-help { font-size: 0.85rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.5; } + +.upload-zone { + border: 2px dashed var(--line); border-radius: var(--radius-lg); + padding: 2rem 1rem; text-align: center; + transition: border-color 0.15s, background 0.15s; + cursor: pointer; margin-bottom: 1rem; +} +.upload-zone:hover, .upload-zone:focus, .upload-zone[data-dragover="true"] { + border-color: var(--accent); background: rgba(var(--accent-rgb), 0.05); + outline: none; +} +.upload-cta { display: flex; flex-direction: column; align-items: center; gap: 0.4rem; } +.upload-icon { font-size: 1.5rem; opacity: 0.6; } +.upload-text { font-size: 1rem; color: var(--text); } +.upload-hint { font-size: 0.8rem; color: var(--text-muted); } + +.upload-progress { margin-top: 1rem; display: flex; flex-direction: column; gap: 0.5rem; } +.upload-progress-row { + display: flex; align-items: center; gap: 0.75rem; + padding: 0.5rem 0.75rem; background: var(--bg-3); + border-radius: var(--radius); font-size: 0.85rem; +} +.upload-progress-row .upload-bar { flex: 1; height: 4px; background: var(--bg); border-radius: 2px; overflow: hidden; } +.upload-progress-row .upload-bar-fill { height: 100%; background: var(--accent); width: 0%; transition: width 0.2s; } + +/* ─── Document list ────────────────────────────────────────────── */ +.doc-list { list-style: none; padding: 0; margin: 0; } +.doc-row { display: flex; align-items: center; gap: 1rem; padding: 0.75rem; border-bottom: 1px solid var(--line); transition: background 0.1s; } +.doc-row:hover { background: var(--bg-3); } +.doc-row.is-disabled { opacity: 0.55; } +.doc-meta { flex: 1; min-width: 0; } +.doc-name { font-size: 0.95rem; color: var(--text); margin: 0; word-break: break-word; } +.doc-info { font-size: 0.75rem; color: var(--text-muted); margin-top: 0.15rem; } +.doc-info .ok { color: var(--success); } +.doc-info .pending { color: var(--warn); } +.doc-info .err { color: var(--danger); } +.doc-actions { display: flex; gap: 0.5rem; align-items: center; } + +.toggle { + position: relative; width: 42px; height: 22px; + background: var(--bg-3); border-radius: 11px; + cursor: pointer; transition: background 0.15s; flex-shrink: 0; +} +.toggle::after { + content: ""; position: absolute; top: 2px; left: 2px; + width: 18px; height: 18px; background: var(--text-muted); + border-radius: 50%; transition: left 0.15s, background 0.15s; +} +.toggle[data-on="true"] { background: rgba(var(--accent-rgb), 0.4); } +.toggle[data-on="true"]::after { left: 22px; background: var(--accent); } + +.btn-icon { + width: 32px; height: 32px; + display: grid; place-items: center; + border-radius: var(--radius); + color: var(--text-muted); + transition: background 0.1s, color 0.1s; +} +.btn-icon:hover { background: var(--bg-3); color: var(--danger); } +.btn-icon-x { + width: 28px; height: 28px; + border-radius: 50%; + color: var(--text-muted); + transition: background 0.1s, color 0.1s; +} +.btn-icon-x:hover { background: var(--bg-3); color: var(--text); } + +.doc-empty, .empty-state { + padding: 1.5rem; text-align: center; + color: var(--text-muted); font-size: 0.9rem; +} + +/* ─── Buttons ──────────────────────────────────────────────────── */ +.btn-primary { + background: var(--accent); color: white; + padding: 0.6rem 1.25rem; border-radius: var(--radius); + font-weight: 500; transition: background 0.15s; +} +.btn-primary:hover { background: var(--accent-dark); } +.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-secondary { + background: var(--bg-3); color: var(--text); + padding: 0.5rem 1rem; border-radius: var(--radius); + font-size: 0.85rem; border: 1px solid var(--line); +} +.btn-secondary:hover { background: var(--bg-4); } +.btn-ghost { + color: var(--text-muted); padding: 0.4rem 0.8rem; + border-radius: var(--radius); font-size: 0.85rem; +} +.btn-ghost:hover { background: var(--bg-3); color: var(--text); } + +/* ─── Login screen ─────────────────────────────────────────────── */ +.login-screen { display: grid; place-items: center; min-height: 60vh; } +.login-card { width: 100%; max-width: 380px; background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 2rem; box-shadow: var(--shadow-lg); } +.login-card h2 { margin: 0 0 0.5rem; font-size: 1.3rem; } +.login-hint { color: var(--text-muted); font-size: 0.9rem; margin: 0 0 1.5rem; } +.login-form { display: flex; flex-direction: column; gap: 1rem; } +.login-form label { display: flex; flex-direction: column; gap: 0.35rem; font-size: 0.85rem; color: var(--text-muted); } +.login-form input { padding: 0.6rem 0.8rem; background: var(--bg); color: var(--text); border: 1px solid var(--line); border-radius: var(--radius); font-size: 0.95rem; } +.login-form input:focus { outline: none; border-color: var(--accent); } +.login-error { background: rgba(239, 68, 68, 0.1); border: 1px solid var(--danger); color: var(--danger); padding: 0.6rem 0.8rem; border-radius: var(--radius); font-size: 0.85rem; } + +/* ─── Klausur cards ────────────────────────────────────────────── */ +.klausur-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 0.75rem; } +.klausur-card { + background: var(--bg-2); border: 1px solid var(--line); + border-radius: var(--radius); padding: 1rem; + display: flex; flex-direction: column; gap: 0.6rem; + transition: border-color 0.15s, transform 0.1s; + cursor: pointer; +} +.klausur-card:hover { border-color: var(--accent); transform: translateY(-1px); } +.klausur-card-header { display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem; } +.klausur-card-num { background: var(--accent); color: white; font-weight: 700; font-size: 0.75rem; padding: 0.15rem 0.5rem; border-radius: 10px; flex-shrink: 0; } +.klausur-card-name { font-weight: 600; font-size: 0.95rem; flex: 1; word-break: break-word; } +.klausur-card-topics { display: flex; flex-wrap: wrap; gap: 0.3rem; } +.klausur-card-topic { background: rgba(var(--accent-rgb), 0.12); color: var(--accent-2); padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.7rem; cursor: pointer; transition: background 0.1s; } +.klausur-card-topic:hover { background: rgba(var(--accent-rgb), 0.25); } +.klausur-card-notes { color: var(--text-muted); font-size: 0.8rem; font-style: italic; } +.klausur-card-cta { margin-top: 0.25rem; font-size: 0.85rem; color: var(--accent-2); display: flex; align-items: center; gap: 0.3rem; } +.klausur-card-actions { display: flex; gap: 0.4rem; margin-top: 0.4rem; flex-wrap: wrap; } +.klausur-card-mini { + font-size: 0.75rem; padding: 0.35rem 0.6rem; border-radius: 4px; + background: var(--bg-4); color: var(--text-muted); + border: 1px solid var(--line); + transition: all 0.1s; +} +.klausur-card-mini:hover { background: var(--bg-3); color: var(--accent-2); border-color: var(--accent); } + +/* ─── Lernen: Heatmap ──────────────────────────────────────────── */ +.heatmap-block { background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 1.25rem; margin-bottom: 1.5rem; } +.heatmap-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem; } +.heatmap-title { margin: 0; font-size: 1rem; } +.heatmap-legend { display: flex; align-items: center; gap: 0.4rem; font-size: 0.75rem; color: var(--text-muted); flex-wrap: wrap; } +.legend-cell { display: inline-block; width: 18px; height: 12px; border-radius: 2px; } +.legend-cell[data-level="0"] { background: var(--bg-4); } +.legend-cell[data-level="2"] { background: rgba(245, 158, 11, 0.55); } +.legend-cell[data-level="4"] { background: rgba(16, 185, 129, 0.6); } +.legend-cell[data-level="5"] { background: rgba(16, 185, 129, 1); } + +.heatmap-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 0.5rem; } +.heatmap-cell { + background: var(--bg-3); border: 1px solid var(--line); + border-radius: var(--radius); padding: 0.75rem; + display: flex; flex-direction: column; gap: 0.3rem; + position: relative; overflow: hidden; + transition: transform 0.1s, border-color 0.1s; + cursor: pointer; +} +.heatmap-cell:hover { transform: translateY(-1px); border-color: var(--accent); } +.heatmap-cell .hc-bar { + position: absolute; bottom: 0; left: 0; right: 0; height: 3px; + background: var(--bg-4); +} +.heatmap-cell .hc-bar-fill { + height: 100%; background: linear-gradient(to right, var(--warn), var(--success)); + transition: width 0.3s; +} +.heatmap-cell .hc-name { font-size: 0.85rem; color: var(--text); font-weight: 500; line-height: 1.3; } +.heatmap-cell .hc-meta { font-size: 0.7rem; color: var(--text-muted); display: flex; justify-content: space-between; } +.heatmap-cell[data-level="0"] .hc-bar-fill { background: var(--bg-4); width: 0%; } +.heatmap-cell[data-level="1"] .hc-bar-fill { width: 20%; } +.heatmap-cell[data-level="2"] .hc-bar-fill { width: 40%; } +.heatmap-cell[data-level="3"] .hc-bar-fill { width: 60%; } +.heatmap-cell[data-level="4"] .hc-bar-fill { width: 80%; } +.heatmap-cell[data-level="5"] .hc-bar-fill { width: 100%; background: var(--success); } + +/* ─── Lernen: Topic picker ─────────────────────────────────────── */ +.topic-picker { background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 1.25rem; margin-bottom: 1.5rem; } +.topic-picker-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; gap: 0.5rem; flex-wrap: wrap; } +.topic-picker-head h3 { margin: 0; font-size: 1rem; } +.select-mini { + background: var(--bg-3); color: var(--text); + border: 1px solid var(--line); border-radius: var(--radius); + padding: 0.4rem 0.6rem; font-size: 0.85rem; +} +.select-mini:focus { outline: none; border-color: var(--accent); } +.topic-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.5rem; } +.topic-pill { + background: var(--bg-3); border: 1px solid var(--line); + border-radius: var(--radius); padding: 0.6rem 0.85rem; + font-size: 0.85rem; color: var(--text); + cursor: pointer; transition: all 0.1s; + display: flex; flex-direction: column; gap: 0.25rem; + text-align: left; +} +.topic-pill:hover { border-color: var(--accent); background: var(--bg-4); } +.topic-pill .tp-label { font-weight: 500; } +.topic-pill .tp-meta { font-size: 0.7rem; color: var(--text-muted); } +.topic-pill[data-mastered="true"] { border-color: var(--success); } +.topic-pill .tp-stars { color: var(--warn); font-size: 0.75rem; letter-spacing: 0.05em; } + +/* ─── Minigame Launcher ────────────────────────────────────────── */ +.minigame-launcher { + background: linear-gradient(135deg, rgba(var(--accent-rgb),0.06), var(--bg-2)); + border: 1px solid var(--accent); + border-radius: var(--radius-lg); + padding: 1.25rem; + margin-bottom: 1.5rem; + animation: fadein 0.2s ease-out; +} +.launcher-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; font-size: 0.95rem; } +.launcher-head strong { color: var(--accent-2); } +.launcher-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.75rem; } +.minigame-card { + background: var(--bg-3); border: 1px solid var(--line); + border-radius: var(--radius); padding: 1rem; + display: flex; flex-direction: column; gap: 0.4rem; + text-align: left; transition: all 0.1s; +} +.minigame-card:hover { border-color: var(--accent); transform: translateY(-1px); background: var(--bg-4); } +.mg-icon { font-size: 1.3rem; } +.mg-title { font-weight: 600; font-size: 0.95rem; color: var(--text); } +.mg-desc { font-size: 0.8rem; color: var(--text-muted); line-height: 1.35; } +.mg-tag { font-size: 0.7rem; color: var(--accent-2); margin-top: 0.25rem; } + +/* ─── Minigame Stage ──────────────────────────────────────────── */ +.minigame-stage { + background: var(--bg-2); border: 1px solid var(--line); + border-radius: var(--radius-lg); padding: 1.5rem; + animation: fadein 0.2s ease-out; +} +.stage-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; gap: 0.5rem; flex-wrap: wrap; } +.stage-title { margin: 0; font-size: 1.05rem; font-weight: 600; } +.stage-progress { color: var(--text-muted); font-size: 0.85rem; } +.stage-progress-bar { height: 4px; background: var(--bg-4); border-radius: 2px; overflow: hidden; margin-bottom: 1rem; } +.stage-progress-bar-fill { height: 100%; background: var(--accent); transition: width 0.3s; } + +.stage-loader { padding: 3rem 1rem; text-align: center; color: var(--text-muted); } +.stage-spinner { + display: inline-block; width: 28px; height: 28px; + border: 3px solid var(--bg-4); border-top-color: var(--accent); + border-radius: 50%; animation: spin 0.7s linear infinite; + margin-bottom: 0.8rem; +} +@keyframes spin { to { transform: rotate(360deg); } } +@keyframes fadein { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } + +/* MCQ-style question block — used by Diagnose + Klausur quiz */ +.qa-card { background: var(--bg-3); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 1.25rem; } +.qa-q { font-size: 1.05rem; font-weight: 500; margin: 0 0 1rem; line-height: 1.5; } +.qa-options { display: flex; flex-direction: column; gap: 0.5rem; } +.qa-option { + text-align: left; padding: 0.75rem 1rem; + background: var(--bg); border: 1px solid var(--line); + border-radius: var(--radius); transition: all 0.1s; + display: flex; gap: 0.75rem; align-items: flex-start; +} +.qa-option:not(:disabled):hover { border-color: var(--accent); background: var(--bg-4); } +.qa-option:disabled { cursor: default; } +.qa-option-letter { font-weight: 600; color: var(--text-muted); flex-shrink: 0; } +.qa-option[data-state="correct"] { border-color: var(--success); background: rgba(16, 185, 129, 0.1); } +.qa-option[data-state="wrong"] { border-color: var(--danger); background: rgba(239, 68, 68, 0.1); } +.qa-option[data-state="reveal-correct"] { border-color: var(--success); } +.qa-feedback { + margin-top: 1rem; padding: 0.85rem 1rem; border-radius: var(--radius); + background: var(--bg); border-left: 3px solid var(--accent); + font-size: 0.9rem; line-height: 1.5; +} +.qa-feedback.correct { border-left-color: var(--success); } +.qa-feedback.wrong { border-left-color: var(--danger); } +.qa-feedback strong { color: var(--accent-2); } +.qa-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; } + +/* Klinikfall stage */ +.fall-patient { background: rgba(56, 189, 248, 0.1); border-left: 3px solid var(--info); padding: 1rem; border-radius: var(--radius); margin-bottom: 1rem; line-height: 1.6; } +.fall-stage-num { color: var(--info); font-weight: 600; font-size: 0.85rem; margin-bottom: 0.25rem; } +.fall-learning { + margin-top: 0.75rem; padding: 0.6rem 0.85rem; + background: rgba(168, 85, 247, 0.08); + border-left: 3px solid var(--accent); + border-radius: var(--radius); + font-size: 0.85rem; color: var(--text-muted); +} +.fall-learning strong { color: var(--accent-2); } + +/* Stimmt-das? */ +.stimmt-grid { display: flex; flex-direction: column; gap: 0.75rem; } +.stimmt-card { + background: var(--bg-3); border: 1px solid var(--line); + border-radius: var(--radius); padding: 1rem; +} +.stimmt-statement { font-size: 0.95rem; margin-bottom: 0.75rem; line-height: 1.5; } +.stimmt-buttons { display: flex; gap: 0.5rem; } +.stimmt-btn { + flex: 1; padding: 0.5rem 1rem; + background: var(--bg); border: 1px solid var(--line); + border-radius: var(--radius); font-weight: 500; + transition: all 0.1s; +} +.stimmt-btn:not(:disabled):hover { border-color: var(--accent); background: var(--bg-4); } +.stimmt-btn[data-state="correct"] { border-color: var(--success); background: rgba(16, 185, 129, 0.15); color: var(--success); } +.stimmt-btn[data-state="wrong"] { border-color: var(--danger); background: rgba(239, 68, 68, 0.15); color: var(--danger); } +.stimmt-explain { margin-top: 0.5rem; font-size: 0.85rem; color: var(--text-muted); padding-left: 0.6rem; border-left: 2px solid var(--line); } +.stimmt-explain.correct { border-left-color: var(--success); } +.stimmt-explain.wrong { border-left-color: var(--danger); } +.trap-tag { display: inline-block; padding: 0.1rem 0.4rem; background: rgba(245, 158, 11, 0.15); color: var(--warn); border-radius: 3px; font-size: 0.7rem; margin-right: 0.4rem; } + +/* Summary card for end of minigame */ +.summary-card { + background: linear-gradient(135deg, rgba(var(--accent-rgb),0.1), var(--bg-3)); + border: 1px solid var(--accent); + border-radius: var(--radius-lg); + padding: 1.5rem; + text-align: center; +} +.summary-stars { + font-size: 2rem; letter-spacing: 0.1em; margin-bottom: 0.5rem; + color: var(--warn); +} +.summary-text { font-size: 1rem; margin-bottom: 0.25rem; } +.summary-meta { color: var(--text-muted); font-size: 0.85rem; margin-bottom: 1rem; } +.summary-actions { display: flex; gap: 0.5rem; justify-content: center; flex-wrap: wrap; } + +/* ─── Persona-Form ─────────────────────────────────────────────── */ +.persona-form { background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 1.25rem; } +.persona-label { display: flex; flex-direction: column; gap: 0.5rem; font-size: 0.85rem; color: var(--text-muted); margin-bottom: 1rem; } +.persona-form textarea { + background: var(--bg); color: var(--text); + border: 1px solid var(--line); border-radius: var(--radius); + padding: 0.75rem 1rem; font-family: inherit; font-size: 0.95rem; + line-height: 1.5; resize: vertical; min-height: 200px; +} +.persona-form textarea:focus { outline: none; border-color: var(--accent); } +.persona-actions { display: flex; justify-content: space-between; align-items: center; gap: 1rem; } +.persona-status { color: var(--text-muted); font-size: 0.85rem; } +.persona-status.saved { color: var(--success); } +.hint-list { color: var(--text-muted); font-size: 0.85rem; line-height: 1.6; margin: 0.5rem 0 1rem; padding-left: 1.5rem; } +.hint-list li { margin-bottom: 0.25rem; } + +/* ─── Telegram-Pairing Block ──────────────────────────────────── */ +.telegram-block { + background: var(--bg-2); border: 1px solid var(--line); + border-radius: var(--radius-lg); padding: 1.25rem; margin-top: 1.5rem; +} +.tg-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; gap: 0.5rem; } +.tg-head h3 { margin: 0; font-size: 1rem; } +.tg-hint { color: var(--text-muted); font-size: 0.9rem; margin: 0 0 1rem; line-height: 1.5; } +.tg-badge { + font-size: 0.75rem; padding: 0.2rem 0.6rem; border-radius: 10px; + background: var(--bg-3); color: var(--text-muted); + border: 1px solid var(--line); +} +.tg-badge.linked { background: rgba(16,185,129,0.15); color: var(--success); border-color: var(--success); } +.tg-badge.unlinked { background: var(--bg-3); color: var(--text-muted); } +.tg-link-area { display: flex; gap: 0.5rem; flex-wrap: wrap; } +.tg-code-display { margin-top: 1rem; } +.tg-code-line { + background: var(--bg); border: 1px solid var(--accent); + border-radius: var(--radius); padding: 0.85rem 1rem; + font-size: 1.1rem; letter-spacing: 0.05em; + margin: 0.5rem 0; + user-select: all; + display: flex; justify-content: center; +} +.tg-code-line code { color: var(--accent-2); font-weight: 600; font-size: 1.15rem; letter-spacing: 0.1em; } +.tg-code-hint { font-size: 0.8rem; color: var(--text-muted); margin: 0.25rem 0 0; } + +/* ─── Chat Dock (resizable + MD) ──────────────────────────────── */ +.chat-dock { + position: fixed; + bottom: 1rem; right: 1rem; + width: 460px; height: 640px; + min-width: 340px; min-height: 420px; + max-width: calc(100vw - 2rem); + max-height: calc(100vh - 2rem); + background: var(--bg-2); border: 1px solid var(--line); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + display: none; flex-direction: column; + z-index: 100; +} +.chat-dock[data-open="true"] { display: flex; } +.dock-resize { + position: absolute; + top: 0; left: 0; + width: 14px; height: 14px; + cursor: nwse-resize; + background: linear-gradient(135deg, transparent 35%, var(--text-dim) 35%, var(--text-dim) 50%, transparent 50%); + border-top-left-radius: var(--radius-lg); + z-index: 1; +} +.dock-resize:hover { background-color: rgba(var(--accent-rgb), 0.1); } +.dock-head { padding: 0.75rem 1rem; border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 0.5rem; } +.dock-title { font-weight: 600; } +.dock-sub { color: var(--text-muted); font-size: 0.85rem; flex: 1; } +.dock-reset, .dock-collapse { width: 28px; height: 28px; border-radius: var(--radius); color: var(--text-muted); } +.dock-reset:hover, .dock-collapse:hover { background: var(--bg-3); color: var(--text); } +.dock-box { flex: 1; padding: 0.75rem 1rem; overflow-y: auto; display: flex; flex-direction: column; gap: 0.75rem; } + +.dock-msg { + max-width: 90%; + padding: 0.7rem 0.9rem; + border-radius: var(--radius); + font-size: 0.92rem; + line-height: 1.55; + word-break: break-word; +} +.dock-msg.user { align-self: flex-end; background: var(--accent-dark); color: white; white-space: pre-wrap; } +.dock-msg.assistant { align-self: flex-start; background: var(--bg-3); color: var(--text); } +.dock-msg.assistant.typing { font-style: italic; opacity: 0.7; } + +/* MD content inside chat */ +.dock-md p { margin: 0 0 0.6rem; } +.dock-md p:last-child { margin-bottom: 0; } +.dock-md h1, .dock-md h2, .dock-md h3 { margin: 0.6rem 0 0.4rem; line-height: 1.3; } +.dock-md h1 { font-size: 1.05rem; color: var(--accent-2); } +.dock-md h2 { font-size: 1rem; color: var(--accent-2); } +.dock-md h3 { font-size: 0.95rem; color: var(--text); } +.dock-md ul, .dock-md ol { margin: 0.4rem 0 0.6rem; padding-left: 1.4rem; } +.dock-md li { margin-bottom: 0.2rem; } +.dock-md code { background: var(--bg); padding: 0.1rem 0.35rem; border-radius: 3px; font-size: 0.85em; color: var(--accent-2); } +.dock-md pre { background: var(--bg); padding: 0.7rem 0.9rem; border-radius: var(--radius); overflow-x: auto; margin: 0.5rem 0; } +.dock-md pre code { background: none; padding: 0; color: var(--text); } +.dock-md strong { color: var(--text); } +.dock-md em { color: var(--text-muted); font-style: italic; } +.dock-md a { color: var(--accent-2); text-decoration: underline; } +.dock-md table.md-table { border-collapse: collapse; margin: 0.5rem 0; font-size: 0.85em; width: auto; } +.dock-md table.md-table th, .dock-md table.md-table td { border: 1px solid var(--line); padding: 0.3rem 0.6rem; } +.dock-md table.md-table th { background: var(--bg); } +.dock-md blockquote { border-left: 3px solid var(--accent); padding-left: 0.8rem; margin: 0.5rem 0; color: var(--text-muted); } + +.dock-msg .sources { + margin-top: 0.6rem; padding-top: 0.5rem; border-top: 1px solid var(--line); + font-size: 0.72rem; color: var(--text-muted); +} +.dock-msg .sources .src-tag { + display: inline-block; background: rgba(var(--accent-rgb), 0.15); color: var(--accent-2); + padding: 0.1rem 0.4rem; border-radius: 3px; margin: 0.15rem 0.25rem 0 0; font-size: 0.7rem; +} + +/* Inline structured renderer (quiz/flashcards/case in chat) */ +.dock-struct { + background: var(--bg); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 0.85rem; + margin-top: 0.5rem; +} +.dock-struct .ds-head { font-weight: 600; color: var(--accent-2); margin-bottom: 0.4rem; font-size: 0.85rem; } +.dock-struct .ds-q { font-size: 0.9rem; margin-bottom: 0.5rem; } +.dock-struct .ds-options { display: flex; flex-direction: column; gap: 0.3rem; } +.dock-struct .ds-option { + text-align: left; padding: 0.4rem 0.6rem; + background: var(--bg-3); border: 1px solid var(--line); + border-radius: 4px; font-size: 0.85rem; +} +.dock-struct .ds-option:hover:not(:disabled) { border-color: var(--accent); } +.dock-struct .ds-option[data-state="correct"] { border-color: var(--success); background: rgba(16, 185, 129, 0.1); } +.dock-struct .ds-option[data-state="wrong"] { border-color: var(--danger); background: rgba(239, 68, 68, 0.1); } + +.dock-form { border-top: 1px solid var(--line); padding: 0.5rem; display: flex; gap: 0.5rem; } +.dock-form textarea { + flex: 1; resize: none; + background: var(--bg); color: var(--text); + border: 1px solid var(--line); border-radius: var(--radius); + padding: 0.5rem 0.7rem; font-size: 0.9rem; + max-height: 140px; min-height: 36px; + font-family: inherit; +} +.dock-form textarea:focus { outline: none; border-color: var(--accent); } +.btn-send { background: var(--accent); color: white; width: 40px; border-radius: var(--radius); font-weight: 700; font-size: 1.1rem; } +.btn-send:hover { background: var(--accent-dark); } + +.dock-open { + position: fixed; bottom: 1rem; right: 1rem; + background: var(--accent); color: white; + padding: 0.7rem 1.2rem; border-radius: 24px; + font-weight: 500; box-shadow: var(--shadow-lg); + display: flex; align-items: center; gap: 0.5rem; + z-index: 99; +} +.dock-open:hover { background: var(--accent-dark); } +.dock-open[hidden] { display: none; } +.dock-open-dot { width: 8px; height: 8px; background: var(--success); border-radius: 50%; display: inline-block; } + +/* ─── Footer ───────────────────────────────────────────────────── */ +.footer { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--line); text-align: center; font-size: 0.8rem; color: var(--text-muted); } + +/* ─── Toasts ───────────────────────────────────────────────────── */ +.toast-stack { position: fixed; top: 1rem; right: 1rem; display: flex; flex-direction: column; gap: 0.5rem; z-index: 200; pointer-events: none; } +.toast { padding: 0.6rem 1rem; background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-md); color: var(--text); font-size: 0.85rem; pointer-events: auto; animation: toast-in 0.2s ease-out; } +.toast.error { border-color: var(--danger); color: var(--danger); } +.toast.success { border-color: var(--success); color: var(--success); } +@keyframes toast-in { from { transform: translateX(20px); opacity: 0; } to { transform: translateX(0); opacity: 1; } } + +/* Mobile */ +@media (max-width: 800px) { + .space-tabs, .folder-tabs { grid-template-columns: repeat(2, 1fr); } + .space-tab { font-size: 0.85rem; padding: 0.7rem 0.4rem; } + .chat-dock { width: calc(100vw - 2rem); height: calc(100vh - 6rem); right: 1rem; bottom: 1rem; } + .dock-resize { display: none; } +} diff --git a/www/cockpit/cockpit.js b/www/cockpit/cockpit.js new file mode 100644 index 0000000..1186142 --- /dev/null +++ b/www/cockpit/cockpit.js @@ -0,0 +1,1099 @@ +/** + * Luna Cockpit v3 — Tutor-Mode UI for physio-tutor (Hanna). + * + * Spaces (top-tabs): Dokumente · Klausuren · Lernen · Steuern + * Lernen-Tab: heatmap + topic-picker + 6 minigames (diagnose / klinikfall / + * stimmt-das / classic-quiz / flashcards / explain) + * Chat-Dock: resizable, MD-rendered, inline quiz/flashcard widgets, source-footer. + */ +(() => { + const API = window.__API_BASE__ || "https://api.qognio.com"; + const SLUG = window.__BOT_SLUG__ || "physio-tutor"; + const BOT_KEY = window.__LUNA_KEY__; + const BOT_ID = window.__BOT_ID__; + const LS_TOKEN = "luna.pb.token"; + const LS_USER = "luna.pb.user"; + const LS_CHAT = "luna.cockpit.chat"; + const LS_DOCK_SIZE = "luna.cockpit.dock-size"; + + const FOLDER_HELP = { + curriculum: + "Was die Uni / Schule offiziell vorgibt: Studienplan, Lernzielkatalog, " + + "Klausurplan. Lädt du hier z.B. inhaltexamen2026.pdf hoch, " + + "kann Luna die Klausur-Themen daraus auslesen.", + official: + "Skripte und Folien deiner Dozent:innen — also alles, was die " + + "Lehrenden offiziell ausgegeben haben.", + own: + "Deine eigenen Notizen, Mitschriften, Markierungen. Luna lernt deinen " + + "persönlichen Stand kennen.", + role: + "Schwerpunkt-Hinweise: aktuelle Lerneinheit, anstehende Termine, " + + "Fokus-Themen — beeinflusst, worauf Luna sich konzentriert.", + }; + + const $ = (sel, root) => (root || document).querySelector(sel); + const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel)); + + // ─── State ────────────────────────────────────────────────────── + let token = localStorage.getItem(LS_TOKEN); + let user = (() => { try { return JSON.parse(localStorage.getItem(LS_USER) || "null"); } catch { return null; } })(); + let activeSpace = "dokumente"; + let activeFolder = "curriculum"; + let docs = []; + let klausuren = []; + let mastery = []; + let curricula = null; + let activeMinigame = null; + let chatHistory = (() => { try { return JSON.parse(localStorage.getItem(LS_CHAT) || "[]"); } catch { return []; } })(); + + // ─── Toasts ───────────────────────────────────────────────────── + function toast(msg, kind = "info") { + const stack = $("#toast-stack"); + const el = document.createElement("div"); + el.className = `toast ${kind}`; + el.textContent = msg; + stack.appendChild(el); + setTimeout(() => { el.style.opacity = "0"; setTimeout(() => el.remove(), 200); }, 3500); + } + + // ─── MD renderer (ported from core/app.js) ───────────────────── + function renderMD(md) { + if (!md) return ""; + let s = md; + s = s.replace(/&/g, "&").replace(//g, ">"); + s = s.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) => `
      ${code}
      `); + s = s.replace(/`([^`\n]+)`/g, "$1"); + // GFM tables + s = s.replace(/(?:^|\n)((?:\|[^\n]*\|[ \t]*\n){2,})/g, (block, content) => { + const lines = content.trim().split("\n"); + if (lines.length < 2) return block; + const sep = lines[1]; + if (!/^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/.test(sep)) return block; + const parseRow = (ln) => ln.replace(/^\|/, "").replace(/\|\s*$/, "").split("|").map(c => c.trim()); + const header = parseRow(lines[0]); + const aligns = parseRow(sep).map(s => /^:-+:$/.test(s) ? "center" : /-+:$/.test(s) ? "right" : "left"); + const rows = lines.slice(2).map(parseRow); + let html = '\n'; + header.forEach((h, i) => { html += ``; }); + html += ""; + rows.forEach(r => { + html += ""; + for (let i = 0; i < Math.max(r.length, header.length); i++) { + html += ``; + } + html += ""; + }); + html += "
      ${h}
      ${r[i] || ""}
      \n"; + return html; + }); + s = s.replace(/\*\*([^*\n]+)\*\*/g, "$1"); + s = s.replace(/__([^_\n]+)__/g, "$1"); + s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2"); + s = s.replace(/^### (.+)$/gm, "

      $1

      "); + s = s.replace(/^## (.+)$/gm, "

      $1

      "); + s = s.replace(/^# (.+)$/gm, "

      $1

      "); + s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '$1'); + s = s.replace(/(?:^|\n)((?:[*\-] .+\n?)+)/g, (m) => { + const items = m.trim().split("\n").map(ln => ln.replace(/^[*\-] /, "")).map(li => `
    • ${li}
    • `).join(""); + return "\n
        " + items + "
      "; + }); + s = s.replace(/(?:^|\n)((?:\d+\. .+\n?)+)/g, (m) => { + const items = m.trim().split("\n").map(ln => ln.replace(/^\d+\. /, "")).map(li => `
    • ${li}
    • `).join(""); + return "\n
        " + items + "
      "; + }); + s = s.split(/\n{2,}/).map(block => { + if (/^<(h\d|ul|ol|pre|blockquote|table)/.test(block.trim())) return block; + return "

      " + block.replace(/\n/g, "
      ") + "

      "; + }).join("\n"); + return s; + } + + function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">","\"":""","'":"'"})[c]); } + function escapeAttr(s) { return escapeHtml(s).replace(/\n/g, " "); } + + // ─── Login ────────────────────────────────────────────────────── + function showLogin() { $("#login-screen").hidden = false; $("#main").hidden = true; $("#dock-open").hidden = true; } + function showApp() { + $("#login-screen").hidden = true; + $("#main").hidden = false; + $("#dock-open").hidden = false; + if (user) { $("#auth-user").textContent = user.email; $("#auth-logout").hidden = false; } + } + $("#login-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const email = $("#login-email").value.trim(); + const pw = $("#login-pw").value; + const errBox = $("#login-error"); + errBox.hidden = true; + try { + const r = await fetch(`${API}/api/collections/users/auth-with-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identity: email, password: pw }), + }); + if (!r.ok) { + const data = await r.json().catch(() => ({})); + throw new Error(data.message || `Login fehlgeschlagen (${r.status})`); + } + const data = await r.json(); + token = data.token; + user = { id: data.record.id, email: data.record.email, name: data.record.name, organization: data.record.organization }; + localStorage.setItem(LS_TOKEN, token); + localStorage.setItem(LS_USER, JSON.stringify(user)); + showApp(); + await refresh(); + } catch (e) { + errBox.textContent = e.message || "Login fehlgeschlagen"; + errBox.hidden = false; + } + }); + $("#auth-logout").addEventListener("click", () => { + localStorage.removeItem(LS_TOKEN); localStorage.removeItem(LS_USER); + token = null; user = null; showLogin(); + }); + + // ─── API helper ──────────────────────────────────────────────── + async function api(path, opts = {}) { + const r = await fetch(`${API}${path}`, { + ...opts, + headers: { + ...(opts.headers || {}), + Authorization: `Bearer ${token}`, + ...(opts.body && !(opts.body instanceof FormData) ? { "Content-Type": "application/json" } : {}), + }, + }); + if (r.status === 401) { showLogin(); throw new Error("session_expired"); } + if (!r.ok) { + const text = await r.text().catch(() => ""); + throw new Error(`API ${path} → ${r.status}: ${text.slice(0, 120)}`); + } + return r.json(); + } + + // ─── Loaders ─────────────────────────────────────────────────── + async function loadCurricula() { + if (curricula) return curricula; + try { + const r = await fetch("/curricula.json", { cache: "no-cache" }); + if (r.ok) curricula = await r.json(); + } catch (e) { console.warn("curricula", e); } + return curricula; + } + async function loadDocs() { + const data = await api(`/api/tutor/documents?bot=${BOT_ID}`); + docs = data.items || []; + renderCounts(); + renderDocList(); + renderStatus(); + } + async function loadKlausuren() { + try { + const data = await api(`/api/tutor/klausuren?bot=${BOT_ID}`); + klausuren = data.items || []; + } catch (e) { console.warn("klausuren", e); klausuren = []; } + renderKlausuren(); + } + async function loadMastery() { + try { + const data = await api(`/api/tutor/mastery?bot=${BOT_ID}`); + mastery = data.items || []; + } catch (e) { mastery = []; } + renderHeatmap(); + renderTopicGrid(); // re-render to update star indicators + } + async function loadPersona() { + try { + const data = await api(`/api/tutor/persona?bot=${BOT_ID}`); + $("#persona-overrides").value = data.overrides || ""; + } catch { /* */ } + } + + // ─── Upload ──────────────────────────────────────────────────── + async function uploadFile(file) { + if (file.size > 20 * 1024 * 1024) { toast(`${file.name}: zu groß (max 20 MB)`, "error"); return; } + const mime = file.type || guessMime(file.name); + const row = document.createElement("div"); + row.className = "upload-progress-row"; + row.innerHTML = `${escapeHtml(file.name)} +
      + `; + $("#upload-progress").hidden = false; + $("#upload-progress").appendChild(row); + const fill = row.querySelector(".upload-bar-fill"); + const stat = row.querySelector(".upload-status"); + try { + const presign = await api("/api/tutor/upload-url", { + method: "POST", + body: JSON.stringify({ bot: BOT_ID, folder: activeFolder, filename: file.name, mime, size: file.size }), + }); + fill.style.width = "20%"; stat.textContent = "S3…"; + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("PUT", presign.url, true); + xhr.setRequestHeader("Content-Type", presign.content_type); + xhr.upload.onprogress = (e) => { if (e.lengthComputable) fill.style.width = (20 + (e.loaded / e.total) * 60) + "%"; }; + xhr.onload = () => xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(`PUT ${xhr.status}`)); + xhr.onerror = () => reject(new Error("network")); + xhr.send(file); + }); + fill.style.width = "85%"; stat.textContent = "registriere…"; + await api("/api/tutor/documents", { + method: "POST", + body: JSON.stringify({ bot: BOT_ID, folder: activeFolder, filename: file.name, mime, size: file.size, s3_key: presign.key }), + }); + fill.style.width = "100%"; stat.textContent = "✓"; stat.style.color = "var(--success)"; + setTimeout(() => row.remove(), 1500); + toast(`${file.name} hochgeladen`, "success"); + await loadDocs(); + // Re-load klausuren after a delay (auto-parse may take a few seconds) + if (activeFolder === "curriculum") setTimeout(() => loadKlausuren(), 6000); + } catch (e) { + console.error(e); + stat.textContent = "✗"; stat.style.color = "var(--danger)"; + toast(`Upload fehlgeschlagen: ${e.message}`, "error"); + } + } + + async function toggleDoc(id) { + const d = docs.find(x => x.id === id); + if (!d) return; + d.enabled = !d.enabled; + renderDocList(); + try { + const r = await api(`/api/tutor/documents/${id}/toggle`, { method: "POST" }); + d.enabled = r.enabled; + renderDocList(); renderStatus(); + } catch { d.enabled = !d.enabled; renderDocList(); toast("Toggle fehlgeschlagen", "error"); } + } + async function deleteDoc(id, name) { + if (!confirm(`„${name}" wirklich löschen? Die Datei wird auch im Bunker entfernt.`)) return; + try { await api(`/api/tutor/documents/${id}`, { method: "DELETE" }); toast("Datei gelöscht", "success"); await loadDocs(); } + catch (e) { toast(`Löschen fehlgeschlagen: ${e.message}`, "error"); } + } + + // ─── Render: Documents space ─────────────────────────────────── + function renderCounts() { + const counts = { curriculum: 0, official: 0, own: 0, role: 0 }; + for (const d of docs) counts[d.folder] = (counts[d.folder] || 0) + 1; + for (const [k, v] of Object.entries(counts)) { + const el = document.querySelector(`.folder-count[data-count="${k}"]`); + if (el) el.textContent = v; + } + } + function renderDocList() { + const list = $("#doc-list"); + const empty = $("#doc-empty"); + const filtered = docs.filter(d => d.folder === activeFolder).sort((a, b) => (b.created || "").localeCompare(a.created || "")); + list.innerHTML = ""; + if (!filtered.length) { empty.hidden = false; return; } + empty.hidden = true; + for (const d of filtered) { + const li = document.createElement("li"); + li.className = `doc-row ${d.enabled ? "" : "is-disabled"}`; + const ext = d.has_text ? "✓ indexiert" : "⌛ indexiere…"; + const sizeKb = d.file_size ? `· ${(d.file_size/1024).toFixed(0)} KB ` : ""; + li.innerHTML = ` +
      +

      ${escapeHtml(d.filename)}

      +

      ${ext} ${sizeKb}· ${relativeTime(d.created)}

      +
      +
      + + +
      `; + list.appendChild(li); + } + list.querySelectorAll(".toggle").forEach(el => el.addEventListener("click", () => toggleDoc(el.dataset.id))); + list.querySelectorAll("[data-del]").forEach(el => el.addEventListener("click", () => deleteDoc(el.dataset.del, el.dataset.name))); + } + function renderStatus() { + const enabled = docs.filter(d => d.enabled); + const byFolder = enabled.reduce((acc, d) => { acc[d.folder] = (acc[d.folder] || 0) + 1; return acc; }, {}); + const summary = $("#status-summary"); + if (!enabled.length && !mastery.length) { + summary.innerHTML = `Noch keine Quellen aktiv. Lade ein Klausurplan-Dokument hoch oder starte eine Diagnose im „Lernen"-Tab.`; + return; + } + const parts = []; + if (byFolder.curriculum) parts.push(`${byFolder.curriculum} Curriculum`); + if (byFolder.official) parts.push(`${byFolder.official} offizielle`); + if (byFolder.own) parts.push(`${byFolder.own} eigene`); + if (byFolder.role) parts.push(`${byFolder.role} Schwerpunkt`); + if (mastery.length) parts.push(`${mastery.length} Themen mit Lernstand`); + summary.innerHTML = `Luna nutzt: ${parts.join(" · ")}. Im „Lernen"-Tab kannst du den Stand visualisieren.`; + } + function setActiveFolder(folder) { + activeFolder = folder; + $$(".folder-tab").forEach(t => t.setAttribute("aria-selected", t.dataset.folder === folder ? "true" : "false")); + $("#folder-help").innerHTML = FOLDER_HELP[folder] || ""; + renderDocList(); + } + + // ─── Render: Klausur cards ───────────────────────────────────── + function renderKlausuren() { + const list = $("#klausur-list"); + const empty = $("#klausur-empty"); + list.innerHTML = ""; + if (!klausuren.length) { list.appendChild(empty); empty.hidden = false; return; } + if (empty) empty.hidden = true; + for (const k of klausuren) { + const card = document.createElement("div"); + card.className = "klausur-card"; + const topicsHtml = (Array.isArray(k.topics) ? k.topics : []).slice(0, 8).map((t, i) => + `${escapeHtml(t)}`).join(""); + const notesHtml = k.notes ? `
      ${escapeHtml(k.notes)}
      ` : ""; + card.innerHTML = ` +
      + ${escapeHtml(String(k.order_index ?? "?"))} + ${escapeHtml(k.name)} +
      +
      ${topicsHtml}
      + ${notesHtml} +
      ▶ Mit Luna durchgehen
      +
      + + + +
      `; + // main click → chat-prep + card.addEventListener("click", (ev) => { + if (ev.target.closest(".klausur-card-mini") || ev.target.closest(".klausur-card-topic")) return; + const prompt = `Bereite mich auf "${k.name}" vor. Mach einen knappen Lernplan über die wichtigsten Themen, dann starten wir mit dem ersten — verstanden?`; + openDock(prompt, true); + }); + // topic-pill click → open minigame for that topic + card.querySelectorAll(".klausur-card-topic").forEach(el => { + el.addEventListener("click", (ev) => { ev.stopPropagation(); openMinigameLauncher(el.dataset.topic, el.dataset.klausur); }); + }); + // mini action buttons + card.querySelectorAll(".klausur-card-mini").forEach(btn => { + btn.addEventListener("click", (ev) => { + ev.stopPropagation(); + const game = btn.dataset.act; + const topicLabel = btn.dataset.name; + launchMinigame(game, { topic_key: topicKeyFromLabel(topicLabel), topic_label: topicLabel }); + }); + }); + list.appendChild(card); + } + } + function topicKeyFromLabel(label) { + return String(label).toLowerCase().replace(/[^a-z0-9äöüß]+/g, "-").replace(/^-|-$/g, ""); + } + + // ─── Render: Heatmap + Topic-Picker ──────────────────────────── + function renderHeatmap() { + const grid = $("#heatmap-grid"); + if (!mastery.length) { + grid.innerHTML = `
      Noch keine Daten. Wähle unten ein Thema und mache einen Kompetenz-Check.
      `; + return; + } + grid.innerHTML = ""; + // Sort by recently-seen + const sorted = [...mastery].sort((a, b) => (b.last_seen_at || "").localeCompare(a.last_seen_at || "")); + for (const m of sorted) { + const cell = document.createElement("div"); + cell.className = "heatmap-cell"; + cell.dataset.level = m.level; + const accuracy = m.attempts ? Math.round((m.correct / m.attempts) * 100) : null; + const accStr = accuracy === null ? "" : `${accuracy}% richtig`; + cell.innerHTML = ` +
      ${escapeHtml(m.topic_label || m.topic_key)}
      +
      + Level ${m.level}/5 + ${accStr} +
      +
      `; + cell.addEventListener("click", () => openMinigameLauncher(m.topic_key, m.topic_label || m.topic_key, m)); + grid.appendChild(cell); + } + } + + function topicGridSource() { + const sel = $("#topic-source").value; + const data = []; + if (sel === "klausur") { + // unique topics from klausuren list + const seen = new Set(); + for (const k of klausuren) { + for (const t of (k.topics || [])) { + const key = topicKeyFromLabel(t); + if (seen.has(key)) continue; + seen.add(key); + data.push({ topic_key: key, topic_label: t, group: k.name }); + } + } + if (!data.length) data.push({ empty: true, msg: "Noch keine Klausuren erkannt — lade einen Klausurplan im Curriculum-Ordner hoch." }); + } else { + // From curricula.json + const cur = curricula?.curricula?.find(c => c.id === sel); + if (!cur) { data.push({ empty: true, msg: "Curriculum nicht geladen." }); } + else { + for (const mod of cur.modules || []) { + data.push({ topic_key: `${cur.id}/${mod.id}`, topic_label: mod.title, group: cur.short || cur.title, curriculum_id: cur.id, module_id: mod.id }); + } + } + } + return data; + } + function renderTopicGrid() { + const grid = $("#topic-grid"); + const items = topicGridSource(); + grid.innerHTML = ""; + if (items[0]?.empty) { + const div = document.createElement("div"); div.className = "empty-state"; div.textContent = items[0].msg; + grid.appendChild(div); return; + } + const lookup = new Map(mastery.map(m => [m.topic_key, m])); + for (const t of items) { + const m = lookup.get(t.topic_key); + const stars = m ? "★".repeat(m.level) + "☆".repeat(5 - m.level) : "☆☆☆☆☆"; + const meta = m ? `Level ${m.level}/5 · ${m.attempts || 0} Versuche` : (t.group || "—"); + const pill = document.createElement("button"); + pill.className = "topic-pill"; + if (m && m.level >= 4) pill.dataset.mastered = "true"; + pill.innerHTML = ` + ${escapeHtml(t.topic_label)} + ${escapeHtml(meta)} + ${stars}`; + pill.addEventListener("click", () => openMinigameLauncher(t.topic_key, t.topic_label, t)); + grid.appendChild(pill); + } + } + + // ─── Minigame launcher ───────────────────────────────────────── + let pendingTopic = null; + function openMinigameLauncher(topicKey, topicLabel, ctx = {}) { + pendingTopic = { + topic_key: topicKey, + topic_label: topicLabel, + curriculum_id: ctx.curriculum_id || (typeof topicKey === "string" && topicKey.includes("/") ? topicKey.split("/")[0] : ""), + module_id: ctx.module_id || (typeof topicKey === "string" && topicKey.includes("/") ? topicKey.split("/")[1] : ""), + }; + $("#launcher-topic").textContent = topicLabel; + $("#minigame-launcher").hidden = false; + $("#minigame-launcher").scrollIntoView({ behavior: "smooth", block: "center" }); + setActiveSpace("lernen"); + } + $("#launcher-close").addEventListener("click", () => { $("#minigame-launcher").hidden = true; pendingTopic = null; }); + $$(".minigame-card").forEach(c => c.addEventListener("click", () => { + if (!pendingTopic) { toast("Bitte erst ein Thema wählen", "error"); return; } + launchMinigame(c.dataset.game, pendingTopic); + })); + + // ─── Minigame runner ──────────────────────────────────────────── + async function launchMinigame(game, topic) { + activeMinigame = { game, topic, state: {} }; + const stage = $("#minigame-stage"); + stage.hidden = false; + $("#minigame-launcher").hidden = true; + stage.innerHTML = `
      Luna bereitet das Spiel vor…
      `; + stage.scrollIntoView({ behavior: "smooth", block: "start" }); + try { + if (game === "diagnose") await runDiagnose(topic); + else if (game === "klinikfall") await runKlinikfall(topic); + else if (game === "stimmt-das") await runStimmtDas(topic); + else if (game === "quiz-classic") await runQuizClassic(topic); + else if (game === "flashcards") await runFlashcards(topic); + else if (game === "explain") await runExplain(topic); + } catch (e) { + stage.innerHTML = `
      Fehler: ${escapeHtml(e.message || String(e))}
      `; + } + } + function endMinigame() { + activeMinigame = null; + $("#minigame-stage").hidden = true; + $("#minigame-stage").innerHTML = ""; + loadMastery(); // refresh heatmap + } + + // Diagnose: 5 MCQ ─────────────────────────────────────────────── + async function runDiagnose(topic) { + const data = await api("/api/tutor/minigame/diagnose", { method: "POST", body: JSON.stringify({ bot: BOT_ID, topic: topic.topic_key, topic_label: topic.topic_label, curriculum_id: topic.curriculum_id, module_id: topic.module_id }) }); + activeMinigame.state = { questions: data.questions, idx: 0, correct: 0, total: data.questions.length, answers: [] }; + renderDiagnoseQuestion(); + } + function renderDiagnoseQuestion() { + const s = activeMinigame.state; + const q = s.questions[s.idx]; + const stage = $("#minigame-stage"); + const progress = ((s.idx) / s.total) * 100; + stage.innerHTML = ` +
      +

      🩺 Kompetenz-Check: ${escapeHtml(activeMinigame.topic.topic_label)}

      + Frage ${s.idx + 1} / ${s.total} +
      +
      +
      +

      ${escapeHtml(q.q)}

      +
      ${q.options.map((opt, i) => ` + `).join("")} +
      + + +
      `; + stage.querySelectorAll(".qa-option").forEach(btn => btn.addEventListener("click", () => answerDiagnose(parseInt(btn.dataset.i, 10)))); + $("#qa-next").addEventListener("click", () => { + s.idx++; + if (s.idx >= s.total) finishDiagnose(); + else renderDiagnoseQuestion(); + }); + } + function answerDiagnose(picked) { + const s = activeMinigame.state; + const q = s.questions[s.idx]; + const correct = picked === q.correct; + if (correct) s.correct++; + s.answers.push({ q: q.q, picked, correct: q.correct, was_right: correct }); + const stage = $("#minigame-stage"); + stage.querySelectorAll(".qa-option").forEach((btn, i) => { + btn.disabled = true; + if (i === q.correct) btn.dataset.state = "correct"; + else if (i === picked) btn.dataset.state = "wrong"; + else btn.dataset.state = "reveal-correct"; + }); + const fb = $("#qa-feedback"); + fb.className = `qa-feedback ${correct ? "correct" : "wrong"}`; + fb.innerHTML = `${correct ? "✓ Richtig" : "✗ Falsch"}. ${renderMD(q.explain || "")}`; + fb.hidden = false; + $("#qa-actions").hidden = false; + } + async function finishDiagnose() { + const s = activeMinigame.state; + await scoreMinigame(s.correct, s.total); + const ratio = s.correct / s.total; + const level = ratio >= 0.95 ? 5 : ratio >= 0.85 ? 4 : ratio >= 0.7 ? 3 : ratio >= 0.5 ? 2 : ratio >= 0.3 ? 1 : 0; + const stars = "★".repeat(level) + "☆".repeat(5 - level); + const verdict = level >= 4 ? "Stark! Du hast das Thema im Griff." : level >= 3 ? "Solide. Ein paar Lücken, gut zum Üben." : level >= 2 ? "Grundlagen vorhanden, Vertiefung nötig." : "Hier lohnt sich gezieltes Üben — mach z.B. einen Klinikfall oder lass dir's erklären."; + const stage = $("#minigame-stage"); + stage.innerHTML = ` +
      +
      ${stars}
      +
      ${s.correct} von ${s.total} richtig — Level ${level}/5
      +
      ${escapeHtml(verdict)}
      +
      + + + +
      +
      `; + $("#sum-explain").addEventListener("click", () => { + const wrongs = s.answers.filter(a => !a.was_right).map(a => a.q).join("\n- "); + openDock(`Wir haben gerade "${activeMinigame.topic.topic_label}" geübt. Diese Fragen waren noch unsicher:\n- ${wrongs}\n\nKannst du mir die Lücken nochmal sokratisch erklären?`, true); + endMinigame(); + }); + $("#sum-klinik").addEventListener("click", () => launchMinigame("klinikfall", activeMinigame.topic)); + $("#sum-close").addEventListener("click", endMinigame); + } + + // Klinikfall ──────────────────────────────────────────────────── + async function runKlinikfall(topic) { + const data = await api("/api/tutor/minigame/klinikfall", { method: "POST", body: JSON.stringify({ bot: BOT_ID, topic: topic.topic_key, topic_label: topic.topic_label }) }); + activeMinigame.state = { case: data, idx: 0, correct: 0, total: data.stages.length, picks: [] }; + renderKlinikIntro(); + } + function renderKlinikIntro() { + const c = activeMinigame.state.case; + const stage = $("#minigame-stage"); + stage.innerHTML = ` +
      +

      🏥 ${escapeHtml(c.title)}

      + Klinikfall +
      +
      ${renderMD(c.patient || "")}
      +
      `; + $("#fall-start").addEventListener("click", () => renderKlinikStage()); + } + function renderKlinikStage() { + const s = activeMinigame.state; + const stg = s.case.stages[s.idx]; + const stage = $("#minigame-stage"); + const progress = ((s.idx) / s.total) * 100; + stage.innerHTML = ` +
      +

      🏥 ${escapeHtml(s.case.title)}

      + Stage ${s.idx + 1} / ${s.total} +
      +
      +
      +
      Entscheidungspunkt ${s.idx + 1}
      +

      ${escapeHtml(stg.prompt)}

      +
      ${stg.options.map((opt, i) => ` + `).join("")} +
      + + +
      `; + stage.querySelectorAll(".qa-option").forEach(btn => btn.addEventListener("click", () => { + const i = parseInt(btn.dataset.i, 10); + const opt = stg.options[i]; + const wasRight = !!opt.correct; + if (wasRight) s.correct++; + s.picks.push({ stage: s.idx, picked: i, was_right: wasRight }); + stage.querySelectorAll(".qa-option").forEach((b, j) => { + b.disabled = true; + const oj = stg.options[j]; + if (oj.correct) b.dataset.state = "correct"; + else if (j === i) b.dataset.state = "wrong"; + }); + const fb = $("#qa-feedback"); + fb.className = `qa-feedback ${wasRight ? "correct" : "wrong"}`; + fb.innerHTML = `${wasRight ? "✓ Stimmt" : "✗ Nicht ideal"}. ${renderMD(opt.reason || "")}`; + if (stg.learning_point) fb.innerHTML += `
      Take-away: ${escapeHtml(stg.learning_point)}
      `; + fb.hidden = false; + $("#qa-actions").hidden = false; + $("#fall-next").addEventListener("click", () => { + s.idx++; + if (s.idx >= s.total) finishKlinik(); + else renderKlinikStage(); + }); + })); + } + async function finishKlinik() { + const s = activeMinigame.state; + await scoreMinigame(s.correct, s.total); + const stage = $("#minigame-stage"); + stage.innerHTML = ` +
      +
      ${s.correct} von ${s.total} Entscheidungen passten
      +
      ${escapeHtml(s.case.synthesis || "Klinikfall abgeschlossen.")}
      +
      + + +
      +
      `; + $("#sum-discuss").addEventListener("click", () => { + openDock(`Ich hab gerade einen Klinikfall zum Thema "${activeMinigame.topic.topic_label}" gemacht (${s.correct}/${s.total}). Was sind die wichtigsten Take-aways die ich mir merken sollte?`, true); + endMinigame(); + }); + $("#sum-close").addEventListener("click", endMinigame); + } + + // Stimmt-das? ─────────────────────────────────────────────────── + async function runStimmtDas(topic) { + const data = await api("/api/tutor/minigame/stimmt-das", { method: "POST", body: JSON.stringify({ bot: BOT_ID, topic: topic.topic_key, topic_label: topic.topic_label }) }); + activeMinigame.state = { items: data.items, picks: [], correct: 0, total: data.items.length }; + renderStimmtDas(); + } + function renderStimmtDas() { + const s = activeMinigame.state; + const stage = $("#minigame-stage"); + stage.innerHTML = ` +
      +

      🤔 Stimmt das? — ${escapeHtml(activeMinigame.topic.topic_label)}

      + 0 / ${s.total} beantwortet +
      +
      + `; + const grid = $("#stimmt-grid"); + s.items.forEach((it, idx) => { + const card = document.createElement("div"); + card.className = "stimmt-card"; + card.innerHTML = ` +
      ${escapeHtml(it.statement)}
      +
      + + +
      + `; + grid.appendChild(card); + }); + grid.querySelectorAll(".stimmt-btn").forEach(btn => btn.addEventListener("click", () => { + const idx = parseInt(btn.dataset.idx, 10); + const pickedTrue = btn.dataset.pick === "true"; + if (s.picks[idx] !== undefined) return; + s.picks[idx] = pickedTrue; + const correct = pickedTrue === !!s.items[idx].is_true; + if (correct) s.correct++; + // Disable both buttons; mark + const card = btn.closest(".stimmt-card"); + card.querySelectorAll(".stimmt-btn").forEach(b => { + b.disabled = true; + const isPick = b.dataset.pick === btn.dataset.pick; + if (isPick) b.dataset.state = correct ? "correct" : "wrong"; + else if (b.dataset.pick === String(!!s.items[idx].is_true)) b.dataset.state = "correct"; + }); + const ex = $(`#sd-ex-${idx}`); + ex.className = `stimmt-explain ${correct ? "correct" : "wrong"}`; + const trapTag = s.items[idx].trap_type && !s.items[idx].is_true ? `${escapeHtml(s.items[idx].trap_type)}` : ""; + ex.innerHTML = `${trapTag}${escapeHtml(s.items[idx].explanation || "")}`; + ex.hidden = false; + $("#sd-progress").textContent = `${s.picks.filter(p => p !== undefined).length} / ${s.total} beantwortet`; + if (s.picks.filter(p => p !== undefined).length === s.total) $("#sd-actions").hidden = false; + })); + $("#sd-finish").addEventListener("click", finishStimmtDas); + } + async function finishStimmtDas() { + const s = activeMinigame.state; + await scoreMinigame(s.correct, s.total); + const ratio = s.correct / s.total; + const stage = $("#minigame-stage"); + stage.innerHTML = ` +
      +
      ${s.correct} von ${s.total} richtig erkannt — ${Math.round(ratio*100)}%
      +
      ${ratio >= 0.8 ? "Du hast die Misconceptions gut gefiltert." : "Mehrere Fallen sind durchgerutscht — guter Hinweis, wo's noch wackelt."}
      +
      + + +
      +
      `; + $("#sum-deepen").addEventListener("click", () => { + const wrongs = s.items.filter((it, i) => s.picks[i] !== !!it.is_true).map(it => it.statement).slice(0, 3).join("\n- "); + openDock(`Beim "Stimmt-das?"-Spiel zu "${activeMinigame.topic.topic_label}" bin ich hier auf Fallen reingefallen:\n- ${wrongs}\n\nLass uns die nochmal sauber durchgehen.`, true); + endMinigame(); + }); + $("#sum-close").addEventListener("click", endMinigame); + } + + // Quiz-classic / Flashcards / Explain — via chat-dock w/ existing engine + async function runQuizClassic(topic) { + endMinigame(); + openDock(`QUIZ_REQUEST topic="${topic.topic_label}" count=10`, true); + } + async function runFlashcards(topic) { + endMinigame(); + openDock(`FLASHCARD_REQUEST topic="${topic.topic_label}" count=8`, true); + } + async function runExplain(topic) { + endMinigame(); + openDock(`Ich erkläre dir jetzt das Thema "${topic.topic_label}" in meinen eigenen Worten — und du gibst mir Feedback zu Klarheit, Vollständigkeit und ob ich Verständnislücken habe. Bist du bereit? Wenn ja, schreib bitte „Los — fang an" und ich erkläre los.`, true); + } + + async function scoreMinigame(correct, total) { + if (!activeMinigame) return; + try { + await api("/api/tutor/minigame/score", { method: "POST", body: JSON.stringify({ + bot: BOT_ID, + topic_key: activeMinigame.topic.topic_key, + topic_label: activeMinigame.topic.topic_label, + curriculum_id: activeMinigame.topic.curriculum_id || "", + module_id: activeMinigame.topic.module_id || "", + correct, total, + }) }); + } catch (e) { console.warn("score", e); } + } + + // ─── Drag & drop / file picker ──────────────────────────────── + function onFiles(files) { if (!files || !files.length) return; for (const f of files) uploadFile(f); } + $("#upload-zone").addEventListener("click", () => $("#file-input").click()); + $("#upload-zone").addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); $("#file-input").click(); }}); + $("#file-input").addEventListener("change", (e) => { onFiles(e.target.files); e.target.value = ""; }); + $("#upload-zone").addEventListener("dragover", (e) => { e.preventDefault(); $("#upload-zone").dataset.dragover = "true"; }); + $("#upload-zone").addEventListener("dragleave", () => { $("#upload-zone").dataset.dragover = "false"; }); + $("#upload-zone").addEventListener("drop", (e) => { e.preventDefault(); $("#upload-zone").dataset.dragover = "false"; onFiles(e.dataTransfer.files); }); + + // Folder + Space-Tabs + $$(".folder-tab").forEach(t => t.addEventListener("click", () => setActiveFolder(t.dataset.folder))); + $$(".space-tab").forEach(t => t.addEventListener("click", () => setActiveSpace(t.dataset.space))); + function setActiveSpace(space) { + activeSpace = space; + $$(".space-tab").forEach(t => t.setAttribute("aria-selected", t.dataset.space === space ? "true" : "false")); + $$(".space[data-space]").forEach(s => s.dataset.active = s.dataset.space === space ? "true" : "false"); + if (space === "lernen") { renderHeatmap(); renderTopicGrid(); } + if (space === "klausuren") renderKlausuren(); + if (space === "steuern") { loadPersona(); refreshTgStatus(); } + } + $("#topic-source").addEventListener("change", renderTopicGrid); + + // ─── Telegram pairing ───────────────────────────────────────── + async function refreshTgStatus() { + try { + const data = await api(`/api/tutor/telegram/status?bot=${BOT_ID}`); + const badge = $("#tg-status-badge"); + const unlinkBtn = $("#tg-unlink"); + if (data.linked) { + badge.textContent = `✓ verbunden ${data.chat_id_preview || ""}`; + badge.className = "tg-badge linked"; + unlinkBtn.hidden = false; + } else { + badge.textContent = "noch nicht verbunden"; + badge.className = "tg-badge unlinked"; + unlinkBtn.hidden = true; + } + } catch (e) { /* */ } + } + $("#tg-generate").addEventListener("click", async () => { + try { + const data = await api("/api/tutor/telegram/link-code", { method: "POST", body: JSON.stringify({ bot: BOT_ID }) }); + $("#tg-code-text").textContent = `/link ${data.code}`; + $("#tg-code-display").hidden = false; + const ttlMin = Math.ceil((data.expires_at - Date.now()) / 60000); + $("#tg-code-ttl").textContent = String(ttlMin); + // periodic poll: when linked, show success + const interval = setInterval(async () => { + const s = await api(`/api/tutor/telegram/status?bot=${BOT_ID}`).catch(() => null); + if (s?.linked) { + clearInterval(interval); + $("#tg-code-display").hidden = true; + toast("✓ Telegram verbunden!", "success"); + refreshTgStatus(); + } + }, 5000); + // Stop polling after TTL + setTimeout(() => clearInterval(interval), data.expires_at - Date.now() + 5000); + } catch (e) { toast(`Fehler: ${e.message}`, "error"); } + }); + $("#tg-unlink").addEventListener("click", async () => { + if (!confirm("Telegram-Verbindung wirklich lösen?")) return; + try { + await api("/api/tutor/telegram/link", { method: "DELETE", body: JSON.stringify({ bot: BOT_ID }) }); + toast("Verbindung gelöst", "success"); + refreshTgStatus(); + } catch (e) { toast(`Fehler: ${e.message}`, "error"); } + }); + + // Persona form + $("#persona-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const overrides = $("#persona-overrides").value; + $("#persona-status").textContent = "speichere…"; + try { + await api("/api/tutor/persona", { method: "POST", body: JSON.stringify({ bot: BOT_ID, overrides }) }); + const status = $("#persona-status"); + status.textContent = "✓ gespeichert"; + status.className = "persona-status saved"; + setTimeout(() => { status.textContent = "—"; status.className = "persona-status"; }, 3000); + toast("Steuerung gespeichert. Luna sieht das ab dem nächsten Chat.", "success"); + } catch (err) { + $("#persona-status").textContent = "Fehler"; + toast(`Speichern fehlgeschlagen: ${err.message}`, "error"); + } + }); + + // ─── Chat dock — resizable, MD, structured-output ────────────── + const dock = $("#chat-dock"); + const dockBox = $("#dock-box"); + const dockForm = $("#dock-form"); + const dockInput = $("#dock-input"); + + // restore saved size + try { + const sz = JSON.parse(localStorage.getItem(LS_DOCK_SIZE) || "null"); + if (sz && sz.w && sz.h) { dock.style.width = sz.w + "px"; dock.style.height = sz.h + "px"; } + } catch { /* */ } + + // Resize via top-left corner drag + const resizer = $("#dock-resize"); + let startX = 0, startY = 0, startW = 0, startH = 0, resizing = false; + resizer.addEventListener("mousedown", (e) => { + e.preventDefault(); + resizing = true; + startX = e.clientX; startY = e.clientY; + startW = dock.offsetWidth; startH = dock.offsetHeight; + document.body.style.userSelect = "none"; + }); + window.addEventListener("mousemove", (e) => { + if (!resizing) return; + const dx = startX - e.clientX, dy = startY - e.clientY; + const newW = Math.max(340, Math.min(window.innerWidth - 32, startW + dx)); + const newH = Math.max(420, Math.min(window.innerHeight - 32, startH + dy)); + dock.style.width = newW + "px"; + dock.style.height = newH + "px"; + }); + window.addEventListener("mouseup", () => { + if (!resizing) return; + resizing = false; + document.body.style.userSelect = ""; + localStorage.setItem(LS_DOCK_SIZE, JSON.stringify({ w: dock.offsetWidth, h: dock.offsetHeight })); + }); + + function openDock(prefill, autosend) { + dock.dataset.open = "true"; $("#dock-open").hidden = true; + renderChat(); + if (prefill) dockInput.value = prefill; + if (autosend) dockForm.requestSubmit(); + setTimeout(() => dockInput.focus(), 100); + } + $("#dock-open").addEventListener("click", () => openDock("", false)); + $("#dock-collapse").addEventListener("click", () => { dock.dataset.open = "false"; $("#dock-open").hidden = false; }); + $("#dock-reset").addEventListener("click", () => { if (confirm("Chat-Verlauf löschen?")) { chatHistory = []; persistChat(); renderChat(); }}); + + function renderChat() { + dockBox.innerHTML = ""; + for (const m of chatHistory) { + const el = document.createElement("div"); + el.className = `dock-msg ${m.role}`; + if (m.role === "assistant") { + if (m.typing) { el.classList.add("typing"); el.textContent = "…"; } + else { + // Try to parse for structured output (quiz/flashcards/case) + const struct = tryParseStruct(m.content); + if (struct) { + el.innerHTML = `
      ${renderMD(stripJsonBlock(m.content))}
      `; + el.appendChild(renderStructInline(struct)); + } else { + el.innerHTML = `
      ${renderMD(m.content)}
      `; + } + if (m.sources && m.sources.length) { + const src = document.createElement("div"); + src.className = "sources"; + src.innerHTML = "📚 Verwendete Quellen: " + m.sources.map(s => + `${escapeHtml(s.folder)}/${escapeHtml(s.filename)}`).join(""); + el.appendChild(src); + } + } + } else { + el.textContent = m.content; + } + dockBox.appendChild(el); + } + dockBox.scrollTop = dockBox.scrollHeight; + } + function tryParseStruct(text) { + if (!text) return null; + const m = text.trim().match(/^(\{[\s\S]*\})\s*$/) || text.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/); + if (!m) return null; + try { + const obj = JSON.parse(m[1]); + if (obj.type === "quiz" && Array.isArray(obj.questions)) return obj; + if (obj.type === "flashcards" && Array.isArray(obj.cards)) return obj; + } catch { return null; } + return null; + } + function stripJsonBlock(text) { + return text.replace(/```(?:json)?\s*\{[\s\S]*?\}\s*```/g, "").replace(/^\{[\s\S]*\}\s*$/, "").trim(); + } + function renderStructInline(obj) { + const w = document.createElement("div"); + w.className = "dock-struct"; + if (obj.type === "quiz") { + w.innerHTML = `
      📝 Quiz · ${escapeHtml(obj.topic || "")}
      `; + let idx = 0, correct = 0; + const total = obj.questions.length; + const renderQ = () => { + const q = obj.questions[idx]; + w.innerHTML = `
      📝 Quiz ${idx+1} / ${total}
      +
      ${escapeHtml(q.q)}
      +
      ${q.options.map((opt, i) => ``).join("")}
      + + `; + w.querySelectorAll(".ds-option").forEach(btn => btn.addEventListener("click", () => { + const i = parseInt(btn.dataset.i, 10); + const c = i === q.correct; + if (c) correct++; + w.querySelectorAll(".ds-option").forEach((b, j) => { + b.disabled = true; + if (j === q.correct) b.dataset.state = "correct"; + else if (j === i) b.dataset.state = "wrong"; + }); + const fb = w.querySelector("#ds-fb"); + fb.className = `qa-feedback ${c?"correct":"wrong"}`; + fb.innerHTML = `${c?"✓":"✗"} ${escapeHtml(q.explain || "")}`; + fb.hidden = false; + w.querySelector("#ds-actions").hidden = false; + w.querySelector("#ds-next").addEventListener("click", () => { + idx++; + if (idx >= total) { w.innerHTML = `
      ✅ Quiz fertig
      ${correct}/${total} richtig.
      `; } + else renderQ(); + }); + })); + }; + renderQ(); + } else if (obj.type === "flashcards") { + w.innerHTML = `
      🃏 Karteikarten · ${escapeHtml(obj.topic || "")}
      `; + let idx = 0; + const total = obj.cards.length; + const renderC = () => { + const c = obj.cards[idx]; + w.innerHTML = `
      🃏 Karte ${idx+1} / ${total}
      +
      ${escapeHtml(c.front)}
      +
      `; + w.querySelector("#ds-flip").addEventListener("click", () => { + w.innerHTML = `
      🃏 Karte ${idx+1} / ${total}
      +
      F: ${escapeHtml(c.front)}

      A: ${escapeHtml(c.back)}${c.hint ? `
      Hinweis: ${escapeHtml(c.hint)}` : ""}
      +
      + + + +
      `; + w.querySelectorAll("[data-r]").forEach(b => b.addEventListener("click", () => { idx++; if (idx >= total) w.innerHTML = `
      ✅ Stapel durch
      `; else renderC(); })); + }); + }; + renderC(); + } + return w; + } + + async function sendChat(text) { + chatHistory.push({ role: "user", content: text }); + persistChat(); renderChat(); + const tmp = { role: "assistant", typing: true }; + chatHistory.push(tmp); renderChat(); + try { + const headers = { "Content-Type": "application/json", Authorization: `Bearer ${BOT_KEY}` }; + // Pass user-token so backend can inject mastery + persona blocks + if (token) headers["X-User-Token"] = token; + const r = await fetch(`https://llm.qognio.com/api/bots/${SLUG}/chat`, { + method: "POST", + headers, + body: JSON.stringify({ + message: text, + history: chatHistory.filter(m => m !== tmp).slice(-10).map(m => ({ role: m.role, content: m.content })), + }), + }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const data = await r.json(); + const idx = chatHistory.indexOf(tmp); + chatHistory[idx] = { role: "assistant", content: data.reply || "(leere Antwort)", sources: data.tutor_sources }; + } catch (e) { + const idx = chatHistory.indexOf(tmp); + chatHistory[idx] = { role: "assistant", content: `Fehler: ${e.message}` }; + } + persistChat(); renderChat(); + } + function persistChat() { localStorage.setItem(LS_CHAT, JSON.stringify(chatHistory.slice(-30))); } + + dockForm.addEventListener("submit", (e) => { + e.preventDefault(); + const text = dockInput.value.trim(); + if (!text) return; + dockInput.value = ""; + dockInput.style.height = "auto"; + sendChat(text); + }); + dockInput.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); dockForm.requestSubmit(); }}); + dockInput.addEventListener("input", () => { dockInput.style.height = "auto"; dockInput.style.height = Math.min(140, dockInput.scrollHeight) + "px"; }); + + // ─── Helpers ─────────────────────────────────────────────────── + function relativeTime(iso) { + if (!iso) return ""; + const d = new Date(iso); + const sec = (Date.now() - d.getTime()) / 1000; + if (sec < 60) return "gerade eben"; + if (sec < 3600) return `vor ${Math.floor(sec/60)} min`; + if (sec < 86400) return `vor ${Math.floor(sec/3600)} h`; + return d.toLocaleDateString("de-DE", { day: "2-digit", month: "short" }); + } + function guessMime(name) { + const ext = name.toLowerCase().split(".").pop(); + return ({ pdf:"application/pdf", txt:"text/plain", md:"text/markdown", csv:"text/csv", json:"application/json", + png:"image/png", jpg:"image/jpeg", jpeg:"image/jpeg", webp:"image/webp" })[ext] || "application/octet-stream"; + } + + async function refresh() { + setActiveFolder(activeFolder); + setActiveSpace(activeSpace); + try { + await loadCurricula(); + await loadDocs(); + await loadKlausuren(); + await loadMastery(); + } catch (e) { console.warn(e); toast("Lade-Fehler — bist du eingeloggt?", "error"); } + } + + // Boot + if (token && user) { showApp(); refresh(); } + else showLogin(); +})(); diff --git a/www/cockpit/index.html b/www/cockpit/index.html new file mode 100644 index 0000000..f63333a --- /dev/null +++ b/www/cockpit/index.html @@ -0,0 +1,290 @@ + + + + + Luna · Tutor-Cockpit + + + + + + + +
      + +
      +
      + +
      + Luna Cockpit + Eigene Unterlagen · Klausur-Plan · Kompetenz-Diagnose +
      +
      +
      +
      + + +
      +
      + + + + +
      + +
      +

      Was Luna gerade weiß

      +
      Lade…
      +
      + + + + + +
      + +
      +
      +
      + +
      + ⬆️ + Datei hier ablegen oder klicken + PDF, Markdown, Text — max 20 MB +
      + +
      +
        + +
        +
        + + +
        +
        +

        📋 Dein Klausur-Plan

        +

        Aus deinem Curriculum extrahiert. Klick auf eine Klausur, um Luna gezielt darauf vorzubereiten.

        +
        +
        +
        Noch keine Klausuren erkannt. Lade ein Klausurplan-Dokument im Curriculum-Ordner hoch.
        +
        +
        + + +
        +
        +

        🧠 Lernen & Üben

        +

        Wähle ein Thema aus deinem Klausur-Plan oder Curriculum, dann starte ein Mini-Spiel.

        +
        + + +
        +
        +

        Dein aktueller Stand

        +
        + 0 noch nicht + 2 in Arbeit + 4 stark + 5 gemeistert +
        +
        +
        +
        Klick auf ein Thema unten, um deinen Stand erstmals zu erfassen.
        +
        +
        + + +
        +
        +

        Themen

        + +
        +
        +
        + + + + + + +
        + + +
        +
        +

        🎛 Bot steuern

        +

        Schreibe hier rein, wie Luna mit dir arbeiten soll. Beispiele:

        +
          +
        • „Antworte ausführlicher und gib mehr klinische Beispiele"
        • +
        • „Stell mir öfter sokratische Rückfragen statt direkt zu erklären"
        • +
        • „Ich lerne lieber visuell — beschreib Diagramme detailliert"
        • +
        • „Fokus auf Klausur 1 — ignoriere andere Themen wenn ich nicht explizit frage"
        • +
        +
        +
        + +
        + + +
        +
        + + +
        +
        +

        📱 Mobil mit Telegram

        + prüfe… +
        +

        + Verbinde diesen Cockpit-Account mit dem + @qognioLunaBot + auf Telegram. Dann kannst du Luna unterwegs fragen — und sie nutzt deinen Lernstand & Dokumente. +

        + + +
        +
        +
        + + + + + + +
        + Sovereign AI · Deutscher Bunker · Qognio · Deine Daten bleiben bei dir +
        +
        +
        + + + diff --git a/www/curricula.json b/www/curricula.json new file mode 100644 index 0000000..4082be8 --- /dev/null +++ b/www/curricula.json @@ -0,0 +1,565 @@ +{ + "version": "2026-04-21", + "updated": "2026-04-21", + "curricula": [ + { + "id": "physiologie-uke", + "title": "Physiologie (UKE Hamburg)", + "short": "UKE iMED + Biomedizin", + "icon": "pulse", + "color": "#ef4444", + "description": "Integrierte Physiologie im UKE-Modellstudiengang iMED. 7 Systemmodule (A–G) mit Herz-Kreislauf, Atmung, Niere, Neuro, Endokrin.", + "modules": [ + { + "id": "zellphysiologie", + "title": "Zellphysiologie & erregbare Zellen", + "objectives": [ + "Ruhemembranpotenzial & Nernst-Gleichung erklären", + "Aktionspotenzial-Phasen beschreiben", + "Synaptische Übertragung (EPSP/IPSP)", + "Na+/K+-ATPase, Kanäle, Transporter" + ], + "topics": ["Membranpotenzial", "Aktionspotenzial", "Synapse", "Ionenkanäle", "Signaltransduktion"] + }, + { + "id": "muskelphysiologie", + "title": "Muskelphysiologie", + "objectives": [ + "Gleitfilamenttheorie & elektromechanische Kopplung", + "Skelett-, Herz-, glatte Muskulatur differenzieren", + "Kraft-Längen- und Kraft-Geschwindigkeits-Kurve", + "Motorische Einheit & Rekrutierung" + ], + "topics": ["Sarkomer", "Aktin-Myosin", "Tetanus", "Ermüdung", "Motor unit"] + }, + { + "id": "herz-kreislauf", + "title": "Herz-Kreislauf-Physiologie", + "objectives": [ + "Herzzyklus & Wiggers-Diagramm interpretieren", + "EKG-Ableitungen & normale Komplexe erkennen", + "Frank-Starling-Mechanismus erklären", + "Blutdruckregulation kurz-/langfristig", + "Mikrozirkulation & Starling-Gleichgewicht" + ], + "topics": ["Herzzyklus", "EKG", "Kontraktilität", "RAAS", "Barorezeptoren", "Kapillaren"] + }, + { + "id": "atmung", + "title": "Atmung & Gasaustausch", + "objectives": [ + "Lungenvolumina & Spirometrie interpretieren", + "Compliance, Resistance, Surfactant", + "Gastransport mit O2-Bindungskurve und Bohr-Effekt", + "Säure-Basen-Haushalt & renal/respiratorische Kompensation" + ], + "topics": ["FRC", "Totraum", "V/Q", "Hämoglobin", "pH", "Bikarbonat"] + }, + { + "id": "niere", + "title": "Niere & Wasser-Elektrolyt-Haushalt", + "objectives": [ + "GFR-Konzept mit Clearance (Inulin, Kreatinin)", + "Tubuläre Reabsorption & Sekretion pro Abschnitt", + "Gegenstromprinzip & ADH", + "RAAS, Blutvolumenregulation" + ], + "topics": ["Nephron", "Clearance", "ADH", "Aldosteron", "Elektrolyte"] + }, + { + "id": "endokrin", + "title": "Endokrinologie", + "objectives": [ + "Hypothalamus-Hypophysen-Achse", + "Schilddrüse, Nebenniere, Pankreas-Inseln", + "Glucose-Homöostase Insulin/Glukagon", + "Ca-Regulation (PTH, Calcitriol, Calcitonin)" + ], + "topics": ["Hormone", "Insulin", "Cortisol", "Schilddrüse", "Calcium"] + }, + { + "id": "nervensystem", + "title": "Nervensystem & Sinne", + "objectives": [ + "Aufbau ZNS/PNS, vegetatives NS", + "Somatosensorik, Schmerz, propriozeptive Wahrnehmung", + "Visuelles, auditives und vestibuläres System", + "Motorisches System: Pyramidenbahn + Kleinhirn" + ], + "topics": ["Sympathikus", "Parasympathikus", "Nozizeption", "Vestibular", "Pyramidenbahn"] + }, + { + "id": "blut", + "title": "Blut & Hämostase", + "objectives": [ + "Erythropoese, Hämoglobin-Varianten", + "Blutgruppen ABO + Rh", + "Primäre & sekundäre Hämostase", + "Fibrinolyse" + ], + "topics": ["Erythrozyten", "ABO", "Thrombozyten", "Gerinnungskaskade"] + } + ] + }, + { + "id": "physiotherapie-aprv", + "title": "Physiotherapie (PhysTh-APrV)", + "short": "Staatliche Ausbildung 2900/1600 h", + "icon": "activity", + "color": "#a855f7", + "description": "Bundesgesetzliche Grundlage für die 3-jährige Physiotherapie-Ausbildung. 2.900 h Theorie + 1.600 h Praxis. Staatliche Prüfung.", + "modules": [ + { + "id": "grundlagen", + "title": "Berufsgrundlagen & Recht", + "objectives": [ + "PhysTh-APrV-Struktur kennen", + "Hygienerichtlinien anwenden", + "Erste Hilfe sicher durchführen", + "Berufs- und Gesetzeskunde" + ], + "topics": ["APrV", "Hygiene", "Erste Hilfe", "Berufsrecht"], + "hours": 100 + }, + { + "id": "anatomie-physio-aprv", + "title": "Anatomie (240 h) & Physiologie (140 h)", + "objectives": [ + "Bewegungsapparat detailliert", + "Innere Organe Situs", + "Neuroanatomie Grundlagen", + "Physiologische Regelkreise" + ], + "topics": ["Muskeln", "Gelenke", "Nerven", "Organe"], + "hours": 380 + }, + { + "id": "krankheitslehre", + "title": "Spezielle Krankheitslehre (360 h)", + "objectives": [ + "Orthopädische Krankheitsbilder", + "Neurologische Syndrome erkennen", + "Innere Medizin Grundlagen", + "Chirurgie und Traumatologie" + ], + "topics": ["Innere", "Ortho", "Neuro", "Chirurgie", "Päd", "Psychiatrie", "Gyn", "Dermato", "Geriatrie", "Rheuma", "Arbeitsmed", "Sportmed"], + "hours": 360 + }, + { + "id": "bewegungslehre", + "title": "Bewegungslehre & Trainingslehre", + "objectives": [ + "Kinematik, Kinetik, biomechanische Prinzipien", + "Trainingsplanung aufbauen", + "Belastungssteuerung (FITT)", + "Motorisches Lernen" + ], + "topics": ["Biomechanik", "Ausdauer", "Kraft", "Koordination", "Beweglichkeit"], + "hours": 160 + }, + { + "id": "befund", + "title": "Befund- und Untersuchungstechniken (100 h)", + "objectives": [ + "Strukturierte Anamnese", + "Gelenkmessung Neutral-Null", + "Muskelfunktionstest (Janda)", + "Spezielle Tests (z. B. Lachman, O'Brien, Thomas)", + "ICF-Dokumentation" + ], + "topics": ["Anamnese", "Inspektion", "Palpation", "ROM", "MFT", "Spezialtests"], + "hours": 100 + }, + { + "id": "kg-techniken", + "title": "Krankengymnastische Behandlungstechniken (500 h)", + "objectives": [ + "Manuelle Therapie (Kaltenborn, Maitland, Mulligan)", + "PNF verstehen und anwenden", + "Bobath, Vojta für Neuro", + "Atemtherapie", + "Schroth bei Skoliose, McKenzie", + "MTT & gerätegestütztes Training" + ], + "topics": ["MT", "PNF", "Bobath", "Vojta", "Schroth", "McKenzie", "Atemtherapie", "MTT"], + "hours": 500 + }, + { + "id": "massage-physik", + "title": "Massage & Physikalische Therapie", + "objectives": [ + "Klassische Massage & Reflexzonen", + "Lymphdrainage nach Vodder", + "Elektrotherapie (TENS, Iontophorese, Galvanik)", + "Hydro-, Balneo-, Thermotherapie", + "Ultraschall, Licht/Strahlen" + ], + "topics": ["Massage", "MLD", "TENS", "Ultraschall", "Fango", "Inhalation"], + "hours": 270 + }, + { + "id": "methodische-anwendung", + "title": "Methodische Anwendung in Fachgebieten (700 h)", + "objectives": [ + "Behandlungsaufbau in Orthopädie, Chirurgie, Innerer Medizin", + "Neurorehabilitation (Schlaganfall, MS, Parkinson)", + "Pädiatrie (Bobath-Säugling, ICP)", + "Psychiatrie (konzentrative Bewegungstherapie)", + "Gynäkologie (Rückbildung)" + ], + "topics": ["Ortho-Reha", "Innere-Reha", "Neuro-Reha", "Päd", "Geriatrie", "Gyn"], + "hours": 700 + }, + { + "id": "pruefung", + "title": "Staatliche Prüfung", + "objectives": [ + "4 schriftliche Aufsichtsarbeiten bestehen", + "3 mündliche Prüfungen (Anatomie, Physiologie, spez. Krankheitslehre)", + "3 praktische Prüfungen an Patient:innen" + ], + "topics": ["Schriftlich", "Mündlich", "Praktisch"] + } + ] + }, + { + "id": "pflegeschule-flensburg", + "title": "Pflege (ÖBiZ/DIAKO Flensburg)", + "short": "Generalistische Pflegeausbildung", + "icon": "heart", + "color": "#ec4899", + "description": "Generalistische Pflegeausbildung nach PflBG/PflAPrV: 2.100 h Theorie + 2.500 h Praxis, 11 curriculare Einheiten, 5 Kompetenzbereiche.", + "modules": [ + { + "id": "ce01", + "title": "CE 01: Ausbildungsstart & wissenschaftliches Fundament", + "objectives": [ + "Pflegeverständnis entwickeln", + "Pflegeprozess (6 Schritte) anwenden", + "Berufsidentität reflektieren" + ], + "topics": ["Pflegeverständnis", "Pflegeprozess", "Berufsrolle"] + }, + { + "id": "ce02", + "title": "CE 02: Hochbelastete & krisenhafte Situationen", + "objectives": [ + "Reanimation durchführen (BLS + AED)", + "Akute Verwirrtheit erkennen", + "Sterbebegleitung gestalten" + ], + "topics": ["Reanimation", "Delir", "Palliativpflege"] + }, + { + "id": "ce03", + "title": "CE 03: Verstehens- und Aushandlungsprozesse", + "objectives": [ + "Patientengespräch gestalten", + "Beobachtung strukturieren", + "Reflexion eigener Einstellungen" + ], + "topics": ["Kommunikation", "Empathie", "Reflexion"] + }, + { + "id": "ce04", + "title": "CE 04: Gesundheitsförderung & Prävention", + "objectives": [ + "Primär-/Sekundär-/Tertiärprävention", + "Lebensstil-Beratung (Bewegung, Ernährung)", + "Impfungen nach STIKO" + ], + "topics": ["Gesundheitsförderung", "Prävention", "Beratung"] + }, + { + "id": "ce05", + "title": "CE 05: Kurative Prozesse & Patientensicherheit", + "objectives": [ + "Medikamenten-Management", + "OP-Vorbereitung und Nachsorge", + "Fehlervermeidung / CIRS" + ], + "topics": ["Medikation", "Perioperativ", "Patientensicherheit"] + }, + { + "id": "ce06", + "title": "CE 06: Akutsituationen", + "objectives": [ + "Notfälle erkennen und erstversorgen", + "Intensivpflege-Basics", + "Monitoring" + ], + "topics": ["Notfall", "ITS", "Monitoring"] + }, + { + "id": "ce07", + "title": "CE 07: Rehabilitation & chronische Erkrankungen", + "objectives": [ + "Rehabilitative Pflege", + "Krankheits-Selbstmanagement fördern", + "Expertenstandards anwenden (Mobilität, Schmerz, Wunden)" + ], + "topics": ["Reha", "Chronisch", "Expertenstandards"] + }, + { + "id": "ce08", + "title": "CE 08: Kritische Lebenssituationen", + "objectives": [ + "Demenzbegleitung", + "Psychiatrische Settings", + "Sucht, Gewalt, Suizidalität" + ], + "topics": ["Demenz", "Psychiatrie", "Sucht"] + }, + { + "id": "ce09", + "title": "CE 09: Eintritt in neue Lebensphasen", + "objectives": [ + "Pädiatrie: Säuglings- und Kinderpflege", + "Wochenbettpflege", + "Geriatrische Eintritts- und Übergangsphasen" + ], + "topics": ["Kinder", "Wochenbett", "Geriatrie"] + }, + { + "id": "ce10", + "title": "CE 10: Kognitive & psychische Beeinträchtigungen", + "objectives": [ + "Demenz-spezifische Pflege (DNQP-Standard)", + "Herausforderndes Verhalten verstehen", + "Milieu- und Biografiearbeit" + ], + "topics": ["Demenz", "Depression", "Verhalten"] + }, + { + "id": "ce11", + "title": "CE 11: Berufliches Selbstverständnis", + "objectives": [ + "Professionalisierung", + "Resilienz", + "Supervision nutzen" + ], + "topics": ["Professionalität", "Resilienz", "Supervision"] + }, + { + "id": "standards", + "title": "Expertenstandards DNQP", + "objectives": [ + "9 Standards kennen und anwenden", + "Dekubitus-, Sturz-, Schmerz-, Wund-, Kontinenz-, Ernährungs-, Entlassungs-, Mobilitäts-, Demenz-Standard" + ], + "topics": ["Dekubitus", "Sturz", "Schmerz", "Wunden", "Kontinenz", "Ernährung", "Entlassung", "Mobilität", "Demenz"] + } + ] + }, + { + "id": "medizinische-terminologie", + "title": "Medizinische Terminologie", + "short": "Latein/Griechisch + Wortbildung", + "icon": "book", + "color": "#f59e0b", + "description": "Pflichtkurs 1. Semester: lateinisch/griechische Fachsprache, Wortbildung, Nomina Anatomica, klinische Terminologie.", + "modules": [ + { + "id": "geschichte", + "title": "Geschichte der Fachsprache", + "objectives": [ + "Hippokrates, Galen, Vesalius verorten", + "Nomina Anatomica / Terminologia Anatomica kennen", + "Rolle des Latein vs. Englisch" + ], + "topics": ["Hippokrates", "Galen", "TA", "Nomenklatur"] + }, + { + "id": "grammatik", + "title": "Lateinische Grammatik (Basics)", + "objectives": [ + "5 Deklinationen sicher beherrschen", + "Adjektiv-Substantiv-Kongruenz", + "Nominativ & Genitiv im Fachbegriff" + ], + "topics": ["Deklination", "Kasus", "Adjektiv", "Plural"] + }, + { + "id": "wortbildung", + "title": "Wortbildung", + "objectives": [ + "Präfix + Stamm + Suffix identifizieren", + "Verbindungsvokale (o/i) richtig setzen", + "Komposita zerlegen" + ], + "topics": ["Präfix", "Suffix", "Kompositum", "Stamm"] + }, + { + "id": "praefixe", + "title": "Wichtige Präfixe", + "objectives": [ + "Griechische Präfixe (a-, dys-, hyper-, hypo-, tachy-, brady-)", + "Lateinische Präfixe (sub-, supra-, inter-, intra-, retro-)", + "Bedeutungsnuancen unterscheiden" + ], + "topics": ["a-/an-", "dys-", "hyper-/hypo-", "tachy-/brady-", "sub-/supra-", "inter-/intra-"] + }, + { + "id": "suffixe", + "title": "Wichtige Suffixe", + "objectives": [ + "-itis vs -ose (entzündlich vs nicht)", + "-ektomie, -otomie, -stomie (operative Eingriffe)", + "-algie, -rrhagie, -ämie" + ], + "topics": ["-itis", "-ose", "-ektomie", "-algie", "-rrhö"] + }, + { + "id": "wortstaemme", + "title": "Wortstämme (Organe)", + "objectives": [ + "Herz (cardi-), Niere (nephr-/ren-), Lunge (pneumo-/pulmo-)", + "Darm (enter-), Leber (hepat-), Magen (gastr-)", + "Synonymie Latein/Griechisch" + ], + "topics": ["cardi-", "nephr-", "hepat-", "gastr-", "neuro-", "arthr-"] + }, + { + "id": "lagebeziehungen", + "title": "Lage & Richtung", + "objectives": [ + "superior/inferior, anterior/posterior etc.", + "3 Ebenen (sagittal, frontal, transversal)", + "proximal/distal am Gliedmaß" + ], + "topics": ["Richtungen", "Ebenen", "Achsen"] + } + ] + }, + { + "id": "anatomie-grundlagen", + "title": "Anatomie & medizinische Grundlagen", + "short": "Vorklinik-Basics für alle", + "icon": "user", + "color": "#10b981", + "description": "Grundlagen der Anatomie, allgemeinen Pathologie, klinischen Untersuchung, Hygiene, Pharmakologie — Querschnitt für alle 4 Kern-Curricula.", + "modules": [ + { + "id": "allg-anatomie", + "title": "Allgemeine Anatomie", + "objectives": [ + "Gewebe (Epithel, Binde, Knorpel, Knochen, Muskel, Nerv)", + "Lagebezeichnungen und Ebenen", + "Bewegungsrichtungen benennen" + ], + "topics": ["Gewebe", "Ebenen", "Bewegungen"] + }, + { + "id": "bewegungsapparat", + "title": "Bewegungsapparat", + "objectives": [ + "Knochen der oberen und unteren Extremität", + "Wichtigste Muskeln mit Ursprung, Ansatz, Funktion", + "Gelenktypen erkennen und beschreiben" + ], + "topics": ["Knochen", "Muskeln", "Gelenke", "Wirbelsäule"] + }, + { + "id": "situs", + "title": "Situs (innere Organe)", + "objectives": [ + "Thorax-Organe in Lagebeziehung", + "Abdomen Ober-/Mittel-/Unterbauch", + "Retroperitoneale Organe" + ], + "topics": ["Thorax", "Abdomen", "Retroperitoneal"] + }, + { + "id": "neuroanatomie", + "title": "Neuroanatomie", + "objectives": [ + "Aufbau Gehirn (Lappen, Hirnstamm, Cerebellum)", + "Rückenmark-Gliederung (31 Segmente)", + "12 Hirnnerven benennen", + "Plexus: cervicalis, brachialis, lumbalis, sacralis" + ], + "topics": ["Gehirn", "Rückenmark", "Hirnnerven", "Plexus"] + }, + { + "id": "biochemie-basics", + "title": "Biochemie-Basics", + "objectives": [ + "Makromoleküle (Kohlenhydrate, Lipide, Proteine, NS)", + "Zitratzyklus und Atmungskette", + "Glukose-ATP-Bilanz" + ], + "topics": ["Stoffwechsel", "ATP", "Aminosäuren", "Fettsäuren"] + }, + { + "id": "pathologie-basics", + "title": "Allgemeine Pathologie", + "objectives": [ + "Nekrose vs. Apoptose", + "Entzündung: 5 Kardinalzeichen", + "Tumorlehre (TNM, Grading)", + "Ischämie, Thrombose, Ödem" + ], + "topics": ["Zelltod", "Entzündung", "Tumor", "Infarkt"] + }, + { + "id": "klinische-untersuchung", + "title": "Klinische Untersuchung", + "objectives": [ + "Strukturierte Anamnese (OPQRST)", + "Vitalzeichen interpretieren", + "Inspektion, Palpation, Perkussion, Auskultation" + ], + "topics": ["Anamnese", "Vitals", "Untersuchung"] + }, + { + "id": "hygiene", + "title": "Hygiene", + "objectives": [ + "5 Indikationen der Händedesinfektion (WHO)", + "Isolationsarten (Kontakt/Tröpfchen/Aerogen)", + "Sterilisation vs. Desinfektion" + ], + "topics": ["Händehygiene", "Isolation", "Sterilisation"] + }, + { + "id": "pharma-basics", + "title": "Pharmakologie-Basics", + "objectives": [ + "LADMET-Prinzip", + "Wichtige Wirkstoffgruppen (NSAR, Antikoagulantien, Antihypertensiva)", + "Kontraindikationen und Interaktionen" + ], + "topics": ["Kinetik", "Dynamik", "Wirkstoffgruppen"] + }, + { + "id": "notfall", + "title": "Notfall-Basics", + "objectives": [ + "BLS-Algorithmus (ERC)", + "Stabile Seitenlage", + "FAST bei Schlaganfall" + ], + "topics": ["Reanimation", "Seitenlage", "Schlaganfall"] + } + ] + } + ], + "badges": [ + {"id": "first_quiz", "title": "Erster Quiz-Durchlauf", "icon": "award", "description": "Du hast dein erstes Quiz absolviert."}, + {"id": "10_quiz_streak", "title": "10er-Serie", "icon": "flame", "description": "10 richtige Antworten in Folge."}, + {"id": "100_answers", "title": "Zentner", "icon": "star", "description": "100 Antworten insgesamt gegeben."}, + {"id": "7_day_streak", "title": "Wochen-Streak", "icon": "calendar", "description": "7 Tage in Folge aktiv."}, + {"id": "curriculum_complete", "title": "Curriculum-Meister", "icon": "crown", "description": "Ein Curriculum vollständig durchgearbeitet."}, + {"id": "night_owl", "title": "Nachteule", "icon": "moon", "description": "Nach 22 Uhr gelernt."}, + {"id": "early_bird", "title": "Frühaufsteher", "icon": "sun", "description": "Vor 7 Uhr gelernt."} + ], + "levels": [ + {"min": 0, "title": "Anfängerin"}, + {"min": 50, "title": "Einsteigerin"}, + {"min": 200, "title": "Fortgeschrittene"}, + {"min": 500, "title": "Profi"}, + {"min": 1250, "title": "Expertin"}, + {"min": 2500, "title": "Meisterin"}, + {"min": 5000, "title": "Großmeisterin"} + ] +} diff --git a/www/index.html b/www/index.html new file mode 100644 index 0000000..d986145 --- /dev/null +++ b/www/index.html @@ -0,0 +1,121 @@ + + + + + Luna · PhysioTutor + + + + + + + +
        + +
        +
        + + Luna PhysioTutor +
        +
        + Online +
        + + + +
        + +
        + + +
        +
        + + +
        +
        +
        + + +
        +
        +
        + + +
        +
        +
        + + +
        +
        +
        +
        + +
        +
        +
        + + + + +
        +
        + +
        + Sovereign AI · Deutscher Bunker · Qognio  ·  DSGVO-konform · Keine externen Fonts · Keine Cookies +
        +
        + +
        + + + + diff --git a/www/styles.css b/www/styles.css new file mode 100644 index 0000000..2d45a13 --- /dev/null +++ b/www/styles.css @@ -0,0 +1,1038 @@ +/* Luna — PhysioTutor Widget */ +:root { + --bg: #0a0a0f; + --bg-elev: #13131a; + --bg-elev-2: #1a1a24; + --border: rgba(255, 255, 255, 0.08); + --border-strong: rgba(255, 255, 255, 0.16); + --text: #f1f0f5; + --text-dim: #a9a8b6; + --text-mute: #6b6a78; + --accent: #a855f7; + --accent-2: #a855f7; + --accent-dim: rgba(168, 85, 247, 0.15); + --accent-strong: rgba(168, 85, 247, 0.45); + --success: #10b981; + --warn: #f59e0b; + --danger: #ef4444; + --radius: 14px; + --radius-sm: 8px; + --shadow: 0 6px 24px rgba(0, 0, 0, 0.35); +} + +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body { height: 100%; } +body { + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--text); + -webkit-font-smoothing: antialiased; + overflow: hidden; +} + +/* Layout */ +.app { + display: grid; + grid-template-rows: auto auto 1fr auto; + height: 100vh; + max-width: 980px; + margin: 0 auto; +} + +/* Header */ +.topbar { + padding: 0.9rem 1.25rem; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 0.75rem; + background: var(--bg); + z-index: 10; +} +.brand { + display: flex; + align-items: center; + gap: 0.55rem; + font-weight: 800; + letter-spacing: -0.02em; + font-size: 1.05rem; +} +.brand-icon { + width: 30px; height: 30px; + display: grid; place-items: center; + background: linear-gradient(140deg, #a855f7 0%, #7c3aed 100%); + border-radius: 9px; + font-size: 1rem; +} +.brand small { font-weight: 500; color: var(--text-dim); font-size: 0.72rem; margin-left: .25rem; } +.spacer { flex: 1; } +.status { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.25rem 0.6rem; + border-radius: 999px; + background: rgba(16, 185, 129, 0.12); + color: var(--success); + font-size: 0.7rem; + font-weight: 500; + border: 1px solid rgba(16, 185, 129, 0.28); +} +.status::before { + content: ""; + width: 6px; height: 6px; + border-radius: 50%; + background: var(--success); + animation: pulse 2s infinite; +} +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} + +/* Tab bar */ +.tabbar { + display: grid; + grid-template-columns: repeat(5, 1fr); + border-bottom: 1px solid var(--border); + background: var(--bg); + position: sticky; + top: 0; + z-index: 5; +} +.tab { + padding: 0.75rem 0.5rem; + text-align: center; + font-size: 0.85rem; + font-weight: 500; + color: var(--text-dim); + background: none; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + font-family: inherit; + transition: all 0.18s ease; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} +.tab:hover { color: var(--text); background: rgba(255, 255, 255, 0.02); } +.tab[aria-selected="true"] { + color: var(--accent); + border-bottom-color: var(--accent); + background: var(--accent-dim); +} +.tab-kbd { + font-size: 0.6rem; + color: var(--text-mute); + font-weight: 400; +} + +/* Main + views */ +.main { + overflow: hidden; + position: relative; +} +.view { + position: absolute; + inset: 0; + padding: 1rem 1.25rem; + overflow-y: auto; + opacity: 0; + transform: translateY(8px); + pointer-events: none; + transition: opacity 0.22s ease, transform 0.22s ease; +} +.view[data-active="true"] { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} + +/* Chat */ +.chat-box { + display: flex; + flex-direction: column; + gap: 0.6rem; + padding: 0.25rem 0; +} +.msg { + max-width: 88%; + padding: 0.7rem 0.95rem; + border-radius: 16px; + line-height: 1.55; + font-size: 0.94rem; + word-wrap: break-word; +} +.msg.user { + background: var(--accent-dim); + border: 1px solid var(--accent-strong); + align-self: flex-end; + border-bottom-right-radius: 4px; +} +.msg.bot { + background: var(--bg-elev); + border: 1px solid var(--border); + align-self: flex-start; + border-bottom-left-radius: 4px; +} +.msg.sys { + align-self: center; + color: var(--text-mute); + font-size: 0.78rem; + font-style: italic; + padding: 0.15rem 0; + background: none; + border: none; + max-width: 100%; + text-align: center; +} +.msg h1, .msg h2, .msg h3 { margin: 0.45rem 0 0.3rem; font-size: 1.02rem; } +.msg p { margin: 0.3rem 0; } +.msg ul, .msg ol { margin: 0.25rem 0 0.3rem 1.2rem; } +.msg li { margin: 0.1rem 0; } +.msg strong { color: #ddd6fe; font-weight: 600; } +.msg em { color: #d6d3de; } +.msg code { + font-family: ui-monospace, Menlo, monospace; + background: rgba(255, 255, 255, 0.08); + padding: 0.05rem 0.35rem; + border-radius: 3px; + font-size: 0.85em; +} +.msg pre { + background: rgba(0, 0, 0, 0.4); + padding: 0.7rem 0.85rem; + border-radius: 8px; + overflow-x: auto; + margin: 0.35rem 0; + border: 1px solid var(--border); +} +.msg pre code { + background: none; + padding: 0; + font-size: 0.82em; +} +.msg a { color: var(--accent); text-decoration: underline; } + +.dots { display: inline-flex; gap: 0.28rem; padding: 0.3rem 0; } +.dots span { + width: 7px; height: 7px; + background: var(--text-dim); + border-radius: 50%; + animation: bounce 1.3s ease-in-out infinite; +} +.dots span:nth-child(2) { animation-delay: 0.15s; } +.dots span:nth-child(3) { animation-delay: 0.3s; } +@keyframes bounce { + 0%, 80%, 100% { opacity: 0.3; transform: scale(0.75); } + 40% { opacity: 1; transform: scale(1); } +} + +/* Composer */ +.composer { + padding: 0.75rem 1.25rem 1rem; + border-top: 1px solid var(--border); + background: var(--bg); + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.composer.dragover { + background: color-mix(in srgb, var(--accent) 10%, var(--bg)); + outline: 2px dashed var(--accent); + outline-offset: -8px; +} +.composer-row { + display: flex; + gap: 0.5rem; + align-items: flex-end; +} +.composer textarea { + flex: 1; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: inherit; + font: inherit; + padding: 0.7rem 0.95rem; + resize: none; + min-height: 46px; + max-height: 140px; + line-height: 1.5; +} +.composer textarea:focus { + outline: 2px solid var(--accent-strong); + outline-offset: -1px; +} +.btn-attach { + min-height: 46px; + width: 46px; + border: 1px solid var(--border); + background: var(--bg-elev); + color: var(--text); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 1.2rem; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: background .12s, border-color .12s; +} +.btn-attach:hover { background: var(--accent); color: #fff; border-color: var(--accent); } +.btn-attach:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; } + +.attach-strip { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} +.attach-strip:empty { display: none; } +.attach-chip { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.3rem 0.55rem; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 999px; + font-size: 0.78rem; + max-width: 280px; +} +.attach-chip.is-error { border-color: var(--danger, #b91c1c); color: var(--danger, #b91c1c); } +.attach-chip-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 180px; +} +.attach-chip-size { color: var(--text-mute); font-variant-numeric: tabular-nums; } +.attach-chip-remove { + background: transparent; + border: 0; + color: inherit; + cursor: pointer; + font-size: 1rem; + line-height: 1; + padding: 0 0.1rem; + opacity: 0.6; +} +.attach-chip-remove:hover { opacity: 1; } +.msg .msg-attachments { + margin-top: 0.4rem; + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} +.msg .msg-attachments .att-name { + font-size: 0.75rem; + padding: 0.15rem 0.5rem; + background: rgba(255,255,255,0.18); + border-radius: 4px; +} +.msg.user .msg-attachments .att-name { background: rgba(255,255,255,0.3); } +.attachment-notice { + margin-top: 0.4rem; + font-size: 0.75rem; + color: var(--text-mute); + font-style: italic; +} +.btn-primary { + background: var(--accent); + color: #fff; + border: none; + padding: 0 1.25rem; + min-height: 46px; + border-radius: var(--radius-sm); + font-weight: 600; + cursor: pointer; + font-family: inherit; + font-size: 0.9rem; + transition: background 0.15s; +} +.btn-primary:hover:not(:disabled) { background: #7c3aed; } +.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-sec { + background: var(--bg-elev); + color: var(--text); + border: 1px solid var(--border-strong); + padding: 0.55rem 0.9rem; + border-radius: var(--radius-sm); + font-weight: 500; + cursor: pointer; + font-family: inherit; + font-size: 0.85rem; + transition: background 0.15s; +} +.btn-sec:hover { background: var(--bg-elev-2); } +.btn-ghost { + background: none; + color: var(--text-dim); + border: 1px solid transparent; + padding: 0.35rem 0.6rem; + border-radius: 6px; + font-family: inherit; + cursor: pointer; + font-size: 0.82rem; +} +.btn-ghost:hover { color: var(--text); background: rgba(255, 255, 255, 0.04); } + +/* Welcome screen */ +.welcome { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 80%; + padding: 1.5rem; + text-align: center; +} +.welcome h2 { + font-size: 1.5rem; + letter-spacing: -0.02em; + margin-bottom: 0.5rem; + background: linear-gradient(140deg, #a855f7, #a855f7); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} +.welcome p { + color: var(--text-dim); + margin-bottom: 1.25rem; + max-width: 38rem; + line-height: 1.55; +} +.mode-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.75rem; + width: 100%; + max-width: 42rem; + margin-bottom: 1rem; +} +.mode-card { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.9rem; + text-align: left; + cursor: pointer; + transition: all 0.18s; + font-family: inherit; + color: inherit; +} +.mode-card:hover { + border-color: var(--accent-strong); + background: var(--bg-elev-2); + transform: translateY(-2px); +} +.mode-card strong { display: block; font-size: 0.95rem; margin-bottom: 0.2rem; } +.mode-card span { color: var(--text-dim); font-size: 0.8rem; } + +/* Quiz */ +.quiz-intro { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 640px; + margin: 0 auto; +} +.topic-select { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem; +} +.topic-select h3 { margin-bottom: 0.5rem; font-size: 1rem; } +.topic-btn-row { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-top: 0.5rem; +} +.topic-pill { + background: var(--bg-elev-2); + border: 1px solid var(--border); + color: var(--text); + padding: 0.4rem 0.8rem; + border-radius: 999px; + font-family: inherit; + font-size: 0.8rem; + cursor: pointer; + transition: all 0.15s; +} +.topic-pill:hover { + border-color: var(--accent-strong); + background: var(--accent-dim); +} +.topic-pill[aria-selected="true"] { + background: var(--accent-dim); + border-color: var(--accent); + color: var(--accent); +} +.quiz-card { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; + max-width: 640px; + margin: 0 auto; +} +.quiz-progress { + display: flex; + justify-content: space-between; + color: var(--text-dim); + font-size: 0.8rem; + margin-bottom: 0.75rem; +} +.quiz-q { + font-size: 1.05rem; + line-height: 1.55; + margin-bottom: 1rem; +} +.quiz-options { display: flex; flex-direction: column; gap: 0.5rem; } +.quiz-option { + background: var(--bg-elev-2); + border: 1px solid var(--border); + color: var(--text); + padding: 0.75rem 1rem; + border-radius: var(--radius-sm); + text-align: left; + font-family: inherit; + font-size: 0.92rem; + cursor: pointer; + transition: all 0.15s; + display: flex; + align-items: center; + gap: 0.75rem; +} +.quiz-option:hover:not(:disabled) { + border-color: var(--accent-strong); + background: var(--accent-dim); +} +.quiz-option .opt-letter { + width: 24px; height: 24px; + display: inline-grid; + place-items: center; + background: var(--bg); + border-radius: 6px; + font-size: 0.75rem; + font-weight: 600; + flex-shrink: 0; +} +.quiz-option.correct { + background: rgba(16, 185, 129, 0.12); + border-color: rgba(16, 185, 129, 0.5); + color: var(--success); +} +.quiz-option.correct .opt-letter { background: var(--success); color: #000; } +.quiz-option.wrong { + background: rgba(239, 68, 68, 0.1); + border-color: rgba(239, 68, 68, 0.5); + color: var(--danger); +} +.quiz-option.wrong .opt-letter { background: var(--danger); color: #fff; } +.quiz-option:disabled { cursor: default; } +.quiz-explain { + margin-top: 1rem; + padding: 0.75rem; + background: var(--bg); + border-left: 3px solid var(--accent); + border-radius: 4px; + font-size: 0.88rem; + line-height: 1.55; + color: var(--text-dim); +} +.quiz-explain strong { color: var(--text); } +.quiz-next { + margin-top: 1rem; + display: flex; + justify-content: flex-end; +} +.quiz-done { + text-align: center; + padding: 1.5rem; +} +.quiz-done h3 { font-size: 1.5rem; margin-bottom: 0.5rem; } +.quiz-done .score { + font-size: 3rem; + font-weight: 700; + background: linear-gradient(140deg, #a855f7, #a855f7); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + margin: 1rem 0; +} +.quiz-done .actions { + display: flex; + gap: 0.5rem; + justify-content: center; + margin-top: 1rem; +} + +/* Flashcards */ +.flash-intro { max-width: 640px; margin: 0 auto; } +.flashcard { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2rem 1.5rem; + max-width: 640px; + margin: 0 auto; + min-height: 280px; + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; + cursor: pointer; + transition: transform 0.3s ease, opacity 0.2s; + user-select: none; +} +.flashcard:hover { border-color: var(--accent-strong); } +.flashcard.flipping { opacity: 0.6; transform: scale(0.98); } +.flashcard-front { + font-size: 1.15rem; + font-weight: 500; + line-height: 1.5; +} +.flashcard-back { + font-size: 0.98rem; + line-height: 1.6; + color: var(--text-dim); +} +.flashcard-back strong { color: var(--text); display: block; margin-bottom: 0.5rem; } +.flashcard-hint { + margin-top: 1rem; + font-size: 0.8rem; + color: var(--text-mute); + font-style: italic; +} +.flash-controls { + max-width: 640px; + margin: 1rem auto 0; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.4rem; +} +.flash-btn { + padding: 0.7rem 0.4rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-elev); + color: var(--text); + font-family: inherit; + font-size: 0.82rem; + cursor: pointer; + font-weight: 500; + transition: all 0.15s; +} +.flash-btn:hover { background: var(--bg-elev-2); } +.flash-btn .label { display: block; font-size: 0.72rem; color: var(--text-mute); margin-top: 2px; } +.flash-btn[data-rating="0"]:hover { border-color: var(--danger); color: var(--danger); } +.flash-btn[data-rating="1"]:hover { border-color: var(--warn); color: var(--warn); } +.flash-btn[data-rating="2"]:hover { border-color: var(--accent); color: var(--accent); } +.flash-btn[data-rating="3"]:hover { border-color: var(--success); color: var(--success); } + +.flash-meta { + max-width: 640px; + margin: 0.5rem auto 0; + display: flex; + justify-content: space-between; + color: var(--text-mute); + font-size: 0.78rem; +} + +/* Progress */ +.progress-grid { + display: grid; + gap: 1rem; + max-width: 640px; + margin: 0 auto; + padding-bottom: 1rem; +} +.stat-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 0.75rem; +} +.stat-card { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.9rem; + text-align: center; +} +.stat-card .val { + font-size: 1.8rem; + font-weight: 700; + letter-spacing: -0.02em; +} +.stat-card .lbl { + color: var(--text-dim); + font-size: 0.78rem; + margin-top: 0.2rem; +} +.stat-card.accent .val { color: var(--accent); } +.stat-card.streak .val { color: var(--warn); } +.stat-card.level .val { color: var(--success); font-size: 1.2rem; } + +.section-card { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem; +} +.section-card h3 { + font-size: 0.95rem; + margin-bottom: 0.75rem; + letter-spacing: -0.01em; +} +.mastery-row { + display: flex; + flex-direction: column; + gap: 0.55rem; +} +.mastery-bar { + display: flex; + flex-direction: column; + gap: 0.25rem; +} +.mastery-head { + display: flex; + justify-content: space-between; + font-size: 0.8rem; +} +.mastery-head .pct { color: var(--accent); font-weight: 600; } +.bar-bg { + height: 6px; + background: var(--bg-elev-2); + border-radius: 3px; + overflow: hidden; +} +.bar-fg { + height: 100%; + background: linear-gradient(90deg, var(--accent) 0%, #a855f7 100%); + border-radius: 3px; + transition: width 0.4s ease; +} + +.badge-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: 0.6rem; +} +.badge { + background: var(--bg-elev-2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.75rem; + text-align: center; + transition: all 0.2s; +} +.badge.earned { + border-color: var(--accent-strong); + background: var(--accent-dim); +} +.badge.locked { opacity: 0.4; } +.badge .icon { + font-size: 1.6rem; + margin-bottom: 0.35rem; + display: block; +} +.badge .title { font-size: 0.78rem; font-weight: 600; } +.badge .desc { font-size: 0.7rem; color: var(--text-mute); margin-top: 0.15rem; line-height: 1.3; } + +/* Curriculum tree */ +.curr-tree { max-width: 720px; margin: 0 auto; } +.curr-root { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 0.6rem; + overflow: hidden; +} +.curr-root-head { + width: 100%; + background: none; + border: none; + color: var(--text); + padding: 0.85rem 1rem; + display: flex; + align-items: center; + gap: 0.75rem; + cursor: pointer; + font-family: inherit; + font-size: 0.95rem; + text-align: left; +} +.curr-root-head:hover { background: rgba(255, 255, 255, 0.02); } +.curr-root-head .ic { + width: 34px; height: 34px; + border-radius: 8px; + display: grid; + place-items: center; + font-size: 1.1rem; + color: #fff; + font-weight: 700; + flex-shrink: 0; +} +.curr-root-head .txt { flex: 1; } +.curr-root-head .txt strong { display: block; } +.curr-root-head .txt small { color: var(--text-dim); font-size: 0.78rem; } +.curr-root-head .chev { + transition: transform 0.2s; + color: var(--text-dim); +} +.curr-root[open] .curr-root-head .chev { transform: rotate(180deg); } +.curr-mods { + padding: 0.2rem 0 0.6rem; + border-top: 1px solid var(--border); +} +.curr-mod { + padding: 0.55rem 1rem 0.55rem 3.75rem; + border-left: 2px solid transparent; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + transition: all 0.15s; +} +.curr-mod:hover { + background: rgba(5, 150, 105, 0.05); + border-left-color: var(--accent); +} +.curr-mod .m-title { + font-size: 0.88rem; + line-height: 1.3; +} +.curr-mod .m-arrow { + color: var(--text-mute); + font-size: 0.9rem; +} +.mod-detail { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; + max-width: 720px; + margin: 0 auto; +} +.mod-detail h3 { margin-bottom: 0.5rem; letter-spacing: -0.01em; } +.mod-detail .breadcrumb { + font-size: 0.8rem; + color: var(--text-dim); + margin-bottom: 0.75rem; +} +.mod-detail .breadcrumb button { + background: none; + border: none; + color: var(--accent); + cursor: pointer; + font-family: inherit; + padding: 0; + font-size: inherit; +} +.mod-detail h4 { margin: 1rem 0 0.35rem; font-size: 0.9rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; } +.mod-detail ul { margin-left: 1.2rem; color: var(--text); } +.mod-detail li { margin: 0.25rem 0; line-height: 1.5; } +.mod-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-top: 1.25rem; +} + +/* Footer */ +.footer { + padding: 0.55rem 1.25rem; + text-align: center; + color: var(--text-mute); + font-size: 0.7rem; + border-top: 1px solid var(--border); +} +.footer a { color: var(--accent); text-decoration: none; } + +/* Toast */ +.toast-stack { + position: fixed; + top: 1rem; + right: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + z-index: 100; + pointer-events: none; +} +.toast { + background: var(--bg-elev); + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: var(--radius-sm); + padding: 0.7rem 0.9rem; + font-size: 0.85rem; + max-width: 300px; + box-shadow: var(--shadow); + animation: toast-in 0.25s ease; + pointer-events: auto; +} +.toast.error { border-left-color: var(--danger); } +.toast.success { border-left-color: var(--success); } +.toast.warn { border-left-color: var(--warn); } +@keyframes toast-in { + from { opacity: 0; transform: translateX(20px); } + to { opacity: 1; transform: translateX(0); } +} + +/* XP gain animation */ +.xp-gain { + position: fixed; + bottom: 5rem; + left: 50%; + transform: translateX(-50%); + background: linear-gradient(140deg, #a855f7, #a855f7); + color: #fff; + padding: 0.5rem 1rem; + border-radius: 999px; + font-weight: 600; + font-size: 0.9rem; + pointer-events: none; + animation: xp-fly 1.5s ease-out forwards; + z-index: 50; +} +@keyframes xp-fly { + 0% { opacity: 0; transform: translate(-50%, 20px) scale(0.8); } + 20% { opacity: 1; transform: translate(-50%, 0) scale(1); } + 80% { opacity: 1; transform: translate(-50%, -40px) scale(1); } + 100% { opacity: 0; transform: translate(-50%, -60px) scale(0.9); } +} + +/* Responsive */ +@media (max-width: 640px) { + .topbar { padding: 0.7rem 0.9rem; } + .brand { font-size: 0.95rem; } + .brand small { display: none; } + .tabbar { grid-template-columns: repeat(5, 1fr); } + .tab { font-size: 0.75rem; padding: 0.6rem 0.2rem; } + .tab-kbd { display: none; } + .view { padding: 0.75rem 0.9rem; } + .composer { padding: 0.6rem 0.9rem 0.75rem; } + .msg { max-width: 92%; font-size: 0.9rem; } + .quiz-card, .flashcard, .mod-detail { padding: 1rem; } + .quiz-q { font-size: 0.98rem; } + .flash-controls { grid-template-columns: repeat(2, 1fr); } + .stat-card .val { font-size: 1.45rem; } + .welcome h2 { font-size: 1.25rem; } + .welcome p { font-size: 0.88rem; } +} + +/* Focus visible */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* Utility */ +.hidden { display: none !important; } +.flex-row { display: flex; align-items: center; gap: 0.5rem; } +.mono { font-family: ui-monospace, Menlo, monospace; } + +/* GFM table (added 2026-04-24) */ +.md-table { width:100%; border-collapse:collapse; margin:.7rem 0; font-size:.92em; background:rgba(255,255,255,0.02); border:1px solid rgba(255,255,255,0.08); border-radius:8px; overflow:hidden; } +.md-table thead { background:rgba(168,85,247,0.12); } +.md-table th, .md-table td { padding:.55rem .8rem; border-bottom:1px solid rgba(255,255,255,0.06); text-align:left; vertical-align:top; } +.md-table th { color:#a855f7; font-weight:600; font-size:.85em; letter-spacing:.02em; text-transform:uppercase; } +.md-table tbody tr:last-child td { border-bottom:none; } +.md-table tbody tr:hover { background:rgba(255,255,255,0.03); } +.md-table code { font-size:.92em; padding:1px 5px; } +.msg.bot .md-table, .chat-message.bot .md-table { display:block; overflow-x:auto; max-width:100%; } + + +/* Deep-Dive bar (flashcards + quiz) — 2026-04-25 */ +.deepdive-bar { + display: flex; + flex-wrap: wrap; + gap: .4rem; + margin-top: .8rem; + padding-top: .6rem; + border-top: 1px dashed rgba(255,255,255,0.08); +} +.deepdive-btn { + font-size: .78em; + padding: .4rem .65rem; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.1); + color: var(--text, #f1f0f5); + border-radius: 6px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.deepdive-btn:hover { + background: rgba(255,255,255,0.08); + border-color: rgba(255,255,255,0.2); +} +.deepdive-panel { + margin-top: .7rem; + padding: .9rem 1rem; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 8px; + font-size: .92em; + line-height: 1.55; +} +.deepdive-panel.hidden { display: none; } +.deepdive-panel .dd-loading { display: flex; align-items: center; gap: .6rem; color: var(--text-mute, #888); font-style: italic; } +.deepdive-panel .dd-body { margin-bottom: .7rem; } +.deepdive-panel .dd-body p:first-child { margin-top: 0; } +.deepdive-panel .dd-body p:last-child { margin-bottom: 0; } +.deepdive-panel .dd-body ul, .deepdive-panel .dd-body ol { margin: .3rem 0 .4rem 1.2rem; } +.deepdive-panel .dd-body li { margin-bottom: .15rem; } +.deepdive-panel .dd-body code { background: rgba(255,255,255,0.08); padding: 1px 5px; border-radius: 3px; } +.deepdive-panel .close-dd { font-size: .78em; padding: .3rem .6rem; margin-top: .4rem; } +@media (max-width: 640px) { .deepdive-bar { gap: .3rem; } .deepdive-btn { font-size: .72em; padding: .35rem .5rem; } } + + +/* Structured-reply fallback in chat (2026-04-25) */ +.structured-chat { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: .9rem 1rem; font-size: .92em; } +.struct-badge { display: inline-block; font-size: .75em; padding: .2rem .55rem; background: rgba(255,255,255,0.08); border-radius: 4px; margin-bottom: .6rem; letter-spacing: .03em; } +.struct-topic { font-weight: 600; font-size: 1.02em; margin-bottom: .5rem; } +.struct-scenario { background: rgba(255,255,255,0.04); padding: .6rem .8rem; border-radius: 6px; margin-bottom: .7rem; line-height: 1.55; } +.struct-question { margin: .7rem 0; padding-left: .4rem; border-left: 2px solid rgba(255,255,255,0.15); } +.struct-options { list-style: none; padding-left: 0; margin: .35rem 0 .5rem 0; } +.struct-options li { padding: .2rem 0; color: var(--text-mute, #bbb); } +.struct-options li.correct { color: var(--success, #10b981); font-weight: 500; } +.struct-explain { font-size: .9em; margin-top: .3rem; padding: .4rem .6rem; background: rgba(255,255,255,0.03); border-radius: 4px; line-height: 1.5; } +.struct-lessons { margin-top: .6rem; font-size: .9em; } +.struct-lessons ul { margin: .25rem 0 .3rem 1.2rem; } +.struct-norms { margin-top: .4rem; font-size: .85em; color: var(--text-mute, #aaa); } +.struct-norms code { background: rgba(255,255,255,0.08); padding: 1px 6px; border-radius: 3px; margin-right: .2rem; } +.struct-flashcard { margin: .5rem 0; padding: .5rem .8rem; background: rgba(255,255,255,0.03); border-radius: 6px; } +.fc-front { font-weight: 500; } +.fc-back { margin-top: .25rem; color: var(--text, #f1f0f5); } +.fc-hint { margin-top: .25rem; font-size: .85em; color: var(--text-mute, #aaa); } +.struct-objectives { margin-bottom: .5rem; font-size: .9em; } +.struct-slide { margin: .5rem 0; padding: .5rem .8rem; border-left: 2px solid rgba(255,255,255,0.15); } +.slide-content { margin-top: .3rem; font-size: .95em; line-height: 1.55; } +.slide-content p:first-child { margin-top: 0; } +.slide-key { margin-top: .35rem; font-size: .88em; color: var(--accent, #f59e0b); } +.struct-hint { margin-top: .7rem; font-size: .78em; color: var(--text-mute, #888); font-style: italic; } + +/* Bot-spezifische Card-Erweiterungen (audit/privacy_check/mail_check/plan/validate/interview/decode/write/calc — 2026-04-25) */ +.struct-row { margin: .35rem 0; line-height: 1.5; } +.struct-row code { background: rgba(255,255,255,0.08); padding: 1px 6px; border-radius: 3px; font-size: .9em; } +.struct-section { margin-top: .65rem; padding-top: .55rem; border-top: 1px solid rgba(255,255,255,0.08); } +.struct-section ul, .struct-section ol { margin: .3rem 0 .3rem 1.2rem; line-height: 1.5; } +.struct-section li { padding: .1rem 0; } +.struct-table { width: 100%; border-collapse: collapse; margin-top: .35rem; font-size: .88em; } +.struct-table th, .struct-table td { padding: .35rem .55rem; border-bottom: 1px solid rgba(255,255,255,0.06); text-align: left; vertical-align: top; } +.struct-table th { color: var(--text-mute, #bbb); font-weight: 500; font-size: .85em; text-transform: uppercase; letter-spacing: .04em; } +.struct-table code { background: rgba(255,255,255,0.06); padding: 1px 5px; border-radius: 3px; } +.struct-step { margin: .4rem 0; padding: .4rem .65rem; background: rgba(255,255,255,0.03); border-left: 2px solid var(--accent, #7c3aed); border-radius: 4px; } +.struct-step .step-head { font-weight: 500; margin-bottom: .25rem; } +.struct-step .step-success { font-size: .85em; color: var(--success, #22c55e); margin-top: .25rem; } +.struct-step ul { margin: .25rem 0 .25rem 1.1rem; font-size: .9em; } +.struct-doc { font-family: Georgia, 'Iowan Old Style', serif; line-height: 1.6; } +.struct-doc blockquote { margin: .4rem 0; padding-left: .75rem; border-left: 3px solid var(--accent, #7c3aed); color: var(--text, #cfcedb); }