init
This commit is contained in:
185
frontend/src/App.vue
Normal file
185
frontend/src/App.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { api, wsVerbinden } from './api.js'
|
||||
import Board from './components/Board.vue'
|
||||
import Guide from './components/Guide.vue'
|
||||
import Ueben from './components/Ueben.vue'
|
||||
import Kennzahlen from './components/Kennzahlen.vue'
|
||||
|
||||
const topics = ref([])
|
||||
const topic = ref('')
|
||||
const state = ref(null)
|
||||
const tab = ref('board')
|
||||
const neuName = ref('')
|
||||
const neuArt = ref('thema')
|
||||
const fehler = ref('')
|
||||
const theme = ref(localStorage.getItem('theme') || 'auto')
|
||||
const navAuf = ref(localStorage.getItem('navAuf') !== '0')
|
||||
|
||||
function navToggle() {
|
||||
navAuf.value = !navAuf.value
|
||||
localStorage.setItem('navAuf', navAuf.value ? '1' : '0')
|
||||
}
|
||||
watch(navAuf, (a) => document.body.classList.toggle('nav-zu', !a), { immediate: true })
|
||||
|
||||
let wsSchliessen = null
|
||||
let timer = null
|
||||
const media = window.matchMedia('(prefers-color-scheme: light)')
|
||||
|
||||
function themeAnwenden() {
|
||||
const hell = theme.value === 'hell' || (theme.value === 'auto' && media.matches)
|
||||
document.documentElement.dataset.theme =
|
||||
theme.value === 'papier' ? 'sepia' : hell ? 'light' : 'dark'
|
||||
localStorage.setItem('theme', theme.value)
|
||||
}
|
||||
watch(theme, themeAnwenden)
|
||||
media.addEventListener('change', themeAnwenden)
|
||||
|
||||
async function topicsLaden() {
|
||||
topics.value = await api.topics()
|
||||
if (!topic.value && topics.value.length) topic.value = topics.value[0].name
|
||||
}
|
||||
|
||||
async function stateLaden() {
|
||||
if (!topic.value) { state.value = null; return }
|
||||
try {
|
||||
state.value = await api.state(topic.value)
|
||||
} catch (e) { fehler.value = String(e) }
|
||||
}
|
||||
|
||||
function onWs() {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(stateLaden, 400)
|
||||
}
|
||||
|
||||
async function anlegen() {
|
||||
if (!neuName.value.trim()) return
|
||||
try {
|
||||
await api.topicAnlegen({ name: neuName.value.trim(), art: neuArt.value })
|
||||
const name = neuName.value.trim()
|
||||
neuName.value = ''
|
||||
await topicsLaden()
|
||||
topic.value = name
|
||||
await stateLaden()
|
||||
} catch (e) { fehler.value = String(e) }
|
||||
}
|
||||
|
||||
const menue = ref(null) // Kontextmenü: { x, y, name }
|
||||
|
||||
function menueOeffnen(e, name) {
|
||||
menue.value = { x: e.clientX, y: e.clientY, name }
|
||||
}
|
||||
|
||||
async function menueLoeschen() {
|
||||
const name = menue.value?.name
|
||||
menue.value = null
|
||||
if (!name) return
|
||||
try {
|
||||
await api.topicLoeschen(name)
|
||||
if (topic.value === name) topic.value = ''
|
||||
await topicsLaden()
|
||||
await stateLaden()
|
||||
} catch (e) { fehler.value = String(e) }
|
||||
}
|
||||
|
||||
async function starten() {
|
||||
try { fehler.value = ''; await api.start(topic.value); await stateLaden() }
|
||||
catch (e) { fehler.value = String(e) }
|
||||
}
|
||||
|
||||
async function stoppen() {
|
||||
await api.stop(topic.value)
|
||||
await stateLaden()
|
||||
}
|
||||
|
||||
async function waehlen(name) {
|
||||
topic.value = name
|
||||
await stateLaden()
|
||||
}
|
||||
|
||||
let poll = null // Fallback, falls während langer LLM-Calls keine WS-Deltas kommen
|
||||
|
||||
onMounted(async () => {
|
||||
themeAnwenden()
|
||||
await topicsLaden()
|
||||
await stateLaden()
|
||||
wsSchliessen = wsVerbinden(onWs)
|
||||
poll = setInterval(() => {
|
||||
if (state.value?.run?.status === 'running') stateLaden()
|
||||
}, 5000)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
wsSchliessen && wsSchliessen()
|
||||
clearInterval(poll)
|
||||
media.removeEventListener('change', themeAnwenden)
|
||||
})
|
||||
|
||||
const tok = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : `${Math.round(n / 1000)}k`)
|
||||
|
||||
function runBadge(run) {
|
||||
if (!run) return null
|
||||
const farbe = { running: 'blau', done: 'gruen', paused: 'gelb', budget: 'gelb',
|
||||
failed: 'rot', stopped: 'rot' }[run.status] || ''
|
||||
return { farbe, text: run.status + (run.ebene && run.status === 'running' ? ` · ${run.ebene}` : '') }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<div class="topics">
|
||||
<div v-for="t in topics" :key="t.name" class="topic-eintrag"
|
||||
:class="{ aktiv: t.name === topic }" @click="waehlen(t.name)"
|
||||
@contextmenu.prevent="menueOeffnen($event, t.name)">
|
||||
<span>{{ t.titel }}</span>
|
||||
<span v-if="t.run?.status === 'running'" class="badge blau">läuft</span>
|
||||
<span v-else-if="t.status === 'fertig'" class="badge gruen">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="neu">
|
||||
<input v-model="neuName" placeholder="neues Thema…" @keyup.enter="anlegen" />
|
||||
<div style="display: flex; gap: 6px">
|
||||
<select v-model="neuArt" style="flex: 1">
|
||||
<option value="thema">thema</option><option value="uni">uni</option>
|
||||
</select>
|
||||
<button class="sekundaer" @click="anlegen">Anlegen</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div v-if="menue" style="position: fixed; inset: 0; z-index: 99"
|
||||
@click="menue = null" @contextmenu.prevent="menue = null">
|
||||
<div class="kontextmenue" :style="{ left: menue.x + 'px', top: menue.y + 'px' }">
|
||||
<button class="rot" @click.stop="menueLoeschen">Thema „{{ menue.name }}" löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hauptbereich">
|
||||
<button class="nav-toggle" :title="navAuf ? 'Navigation einklappen' : 'Navigation anzeigen'"
|
||||
@click="navToggle">☰</button>
|
||||
<div class="topbar">
|
||||
<div class="tabs">
|
||||
<button v-for="t in ['board', 'guide', 'üben', 'kennzahlen']" :key="t"
|
||||
:class="{ aktiv: tab === t }" @click="tab = t">{{ t }}</button>
|
||||
</div>
|
||||
<button @click="starten" :disabled="!topic || state?.run?.status === 'running'">Start</button>
|
||||
<button class="sekundaer" @click="stoppen" :disabled="state?.run?.status !== 'running'">Stop</button>
|
||||
<span v-if="state?.run" class="badge" :class="runBadge(state.run).farbe">{{ runBadge(state.run).text }}</span>
|
||||
<span v-if="state" class="badge">{{ tok(state.verbraucht) }} Tokens</span>
|
||||
<span v-if="state?.run?.grund" class="badge gelb">{{ state.run.grund }}</span>
|
||||
<span v-if="fehler" class="badge rot">{{ fehler }}</span>
|
||||
<span style="flex: 1"></span>
|
||||
<select v-model="theme" title="Farbschema">
|
||||
<option value="hell">hell</option>
|
||||
<option value="dunkel">dunkel</option>
|
||||
<option value="papier">papier</option>
|
||||
<option value="auto">auto</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="inhalt">
|
||||
<Board v-if="tab === 'board'" :state="state" :topic="topic" @reload="stateLaden" />
|
||||
<Guide v-else-if="tab === 'guide'" :topic="topic" :state="state" />
|
||||
<Ueben v-else-if="tab === 'üben'" :topic="topic" />
|
||||
<Kennzahlen v-else :run="state?.run" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
44
frontend/src/api.js
Normal file
44
frontend/src/api.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const BASE = import.meta.env.DEV ? 'http://localhost:8000' : ''
|
||||
|
||||
async function req(pfad, opts = {}) {
|
||||
const res = await fetch(BASE + pfad, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...opts,
|
||||
})
|
||||
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export const api = {
|
||||
topics: () => req('/api/topics'),
|
||||
topicAnlegen: (daten) => req('/api/topics', { method: 'POST', body: JSON.stringify(daten) }),
|
||||
start: (topic, budget) =>
|
||||
req(`/api/topics/${topic}/start`, { method: 'POST', body: JSON.stringify({ budget }) }),
|
||||
stop: (topic) => req(`/api/topics/${topic}/stop`, { method: 'POST', body: '{}' }),
|
||||
sollReset: (topic) => req(`/api/topics/${topic}/soll-reset`, { method: 'POST', body: '{}' }),
|
||||
vollReset: (topic) => req(`/api/topics/${topic}/voll-reset`, { method: 'POST', body: '{}' }),
|
||||
setAuto: (topic, ebene, an) =>
|
||||
req(`/api/topics/${topic}/auto`, { method: 'PATCH', body: JSON.stringify({ ebene, an }) }),
|
||||
ebeneEntfernen: (topic, ebene) =>
|
||||
req(`/api/topics/${topic}/ebene/${ebene}/entfernen`, { method: 'POST', body: '{}' }),
|
||||
topicLoeschen: (topic) => req(`/api/topics/${topic}`, { method: 'DELETE' }),
|
||||
state: (topic) => req(`/api/topics/${topic}/state`),
|
||||
guide: (topic) => req(`/api/topics/${topic}/guide`),
|
||||
ueben: (topic) => req(`/api/topics/${topic}/ueben`),
|
||||
antwort: (artefakt_id, richtig) =>
|
||||
req('/api/ueben/antwort', { method: 'POST', body: JSON.stringify({ artefakt_id, richtig }) }),
|
||||
kennzahlen: (runId) => req(`/api/runs/${runId}/kennzahlen`),
|
||||
}
|
||||
|
||||
export function wsVerbinden(onEvent) {
|
||||
const url = (import.meta.env.DEV ? 'ws://localhost:8000' : `ws://${location.host}`) + '/ws'
|
||||
let ws
|
||||
const verbinden = () => {
|
||||
ws = new WebSocket(url)
|
||||
ws.onopen = () => onEvent({ typ: 'verbunden' }) // nach Reconnect: Stand neu laden
|
||||
ws.onmessage = (e) => onEvent(JSON.parse(e.data))
|
||||
ws.onclose = () => setTimeout(verbinden, 2000)
|
||||
}
|
||||
verbinden()
|
||||
return () => ws && ws.close()
|
||||
}
|
||||
177
frontend/src/components/Board.vue
Normal file
177
frontend/src/components/Board.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<script setup>
|
||||
// Ebenen-Kanban: 5 Spalten = Pipeline links→rechts, jede Spalte selbst steuerbar.
|
||||
// Befunde + laufende Agenten als Leiste ÜBER dem Board.
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { api } from '../api.js'
|
||||
|
||||
const props = defineProps({ state: Object, topic: String })
|
||||
const emit = defineEmits(['reload'])
|
||||
|
||||
// Agenten-Zeit lokal weiterticken: der Server liefert laufzeit nur mit dem
|
||||
// State-Snapshot — zwischen Deltas (lange LLM-Calls) fröre die Anzeige ein.
|
||||
const jetzt = ref(Date.now())
|
||||
const empfangen = ref(Date.now())
|
||||
const ebenenStart = ref({}) // Ebene → lokaler Start-Anker (epoch ms), monoton
|
||||
watch(() => props.state, () => {
|
||||
empfangen.value = Date.now()
|
||||
const neu = {}
|
||||
for (const e of props.state?.ebenen || []) {
|
||||
if (!e.laeuft) continue
|
||||
// Server-Dauer ist eine Untergrenze (Event-zu-Event); frühester Anker gewinnt,
|
||||
// damit die Anzeige beim nächsten State-Load nicht auf 0 zurückspringt
|
||||
const ausServer = Date.now() - (e.dauer_s || 0) * 1000
|
||||
const bisher = ebenenStart.value[e.name]
|
||||
neu[e.name] = bisher ? Math.min(bisher, ausServer) : ausServer
|
||||
}
|
||||
ebenenStart.value = neu
|
||||
})
|
||||
const ticker = setInterval(() => { jetzt.value = Date.now() }, 1000)
|
||||
onUnmounted(() => clearInterval(ticker))
|
||||
const extraSek = computed(() => Math.max(0, (jetzt.value - empfangen.value) / 1000))
|
||||
|
||||
function ebenenDauer(key) {
|
||||
const i = info(key)
|
||||
if (i.laeuft && ebenenStart.value[key])
|
||||
return Math.max(i.dauer_s || 0, Math.round((jetzt.value - ebenenStart.value[key]) / 1000))
|
||||
return i.dauer_s || 0
|
||||
}
|
||||
|
||||
const EBENEN = [
|
||||
{ key: 'korpus', titel: 'Quellen' },
|
||||
{ key: 'inventar', titel: 'Atome' },
|
||||
{ key: 'artefakte', titel: 'Artefakte' },
|
||||
{ key: 'struktur', titel: 'Bausteine' },
|
||||
{ key: 'guide', titel: 'Guide' },
|
||||
]
|
||||
|
||||
const statusFarbe = (s) => ({
|
||||
bestaetigt: 'gruen', verifiziert: 'gruen', fertig: 'gruen', done: 'gruen', aktiv: 'gruen',
|
||||
extrahiert: 'blau', atome: 'blau', kandidat: 'gelb', abgedeckt: 'gelb',
|
||||
repair: 'gelb', ohne_anker: 'rot', verworfen: 'rot',
|
||||
}[s] || '')
|
||||
|
||||
const info = (key) => props.state?.ebenen?.find((e) => e.name === key) || {}
|
||||
|
||||
const aktiveAtome = computed(() =>
|
||||
(props.state?.atome || []).filter((a) => !['gemerged', 'verworfen'].includes(a.status)))
|
||||
|
||||
function karten(key) {
|
||||
const s = props.state
|
||||
if (!s) return []
|
||||
if (key === 'korpus') {
|
||||
return [
|
||||
...s.quellen.map((q) => ({ id: `q${q.id}`, text: q.titel,
|
||||
badges: [[q.status, statusFarbe(q.status)], [q.rolle, q.rolle === 'aufgaben' ? 'gelb' : 'blau']] })),
|
||||
...s.soll.map((p) => ({ id: `s${p.id}`, text: p.punkt,
|
||||
badges: [[p.status, statusFarbe(p.status)]] })),
|
||||
]
|
||||
}
|
||||
if (key === 'inventar') {
|
||||
return aktiveAtome.value.map((a) => ({ id: a.id, text: a.titel,
|
||||
badges: [[a.status, statusFarbe(a.status)], [`${a.typ} · ${a.level}`, '']] }))
|
||||
}
|
||||
if (key === 'artefakte') {
|
||||
return aktiveAtome.value.map((a) => ({ id: a.id, text: a.titel,
|
||||
badges: [[`${a.artefakte.verifiziert}/${a.artefakte.gesamt} verifiziert`,
|
||||
a.artefakte.verifiziert > 0 ? 'gruen' : '']] }))
|
||||
}
|
||||
if (key === 'struktur') {
|
||||
return s.bausteine.map((b) => ({ id: b.id, text: `${b.ord + 1}. ${b.titel}`,
|
||||
badges: [[b.status, statusFarbe(b.status)]] }))
|
||||
}
|
||||
return s.bausteine.map((b) => ({ id: b.id, text: `${b.ord + 1}. ${b.titel}`,
|
||||
badges: [[b.stage || 'offen', b.stage === 'done' ? 'gruen' : 'blau']] }))
|
||||
}
|
||||
|
||||
function generierenErlaubt(idx) {
|
||||
if (!props.state || props.state.run?.status === 'running') return false
|
||||
const e = info(EBENEN[idx].key)
|
||||
if (e.fertig) return false
|
||||
return EBENEN.slice(0, idx).every((v) => info(v.key).fertig)
|
||||
}
|
||||
|
||||
const menue = ref(null) // Spalten-Kontextmenü: { x, y, key, titel, idx }
|
||||
|
||||
function menueOeffnen(ev, e, idx) {
|
||||
menue.value = { x: ev.clientX, y: ev.clientY, key: e.key, titel: e.titel, idx }
|
||||
}
|
||||
|
||||
async function menueGenerieren() {
|
||||
const m = menue.value
|
||||
menue.value = null
|
||||
if (!m || !generierenErlaubt(m.idx)) return
|
||||
await api.start(props.topic)
|
||||
emit('reload')
|
||||
}
|
||||
|
||||
async function menueEntfernen() {
|
||||
const m = menue.value
|
||||
menue.value = null
|
||||
if (!m) return
|
||||
await api.ebeneEntfernen(props.topic, m.key)
|
||||
emit('reload')
|
||||
}
|
||||
|
||||
async function autoSetzen(key, ev) {
|
||||
await api.setAuto(props.topic, key, ev.target.checked)
|
||||
emit('reload')
|
||||
}
|
||||
|
||||
const dauer = (s) => (s >= 3600 ? `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
|
||||
: s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`)
|
||||
const mmss = (s) => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(Math.floor(s % 60)).padStart(2, '0')}`
|
||||
const k = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : n)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="state">
|
||||
<div v-if="state.agenten.length" class="leiste">
|
||||
<strong style="font-size: 12px; color: var(--dim); white-space: nowrap">
|
||||
Agenten ({{ state.agenten.length }})
|
||||
</strong>
|
||||
<span v-for="a in state.agenten" :key="a.key" class="badge blau"
|
||||
:title="a.key">{{ mmss(a.laufzeit + extraSek) }}</span>
|
||||
</div>
|
||||
<div class="board">
|
||||
<div v-for="(e, idx) in EBENEN" :key="e.key" class="spalte"
|
||||
@contextmenu.prevent="menueOeffnen($event, e, idx)">
|
||||
<div class="spaltekopf">
|
||||
<div class="zeile">
|
||||
<span class="titel">{{ e.titel }}
|
||||
<span class="badge">{{ karten(e.key).length }}</span></span>
|
||||
<span class="badge" :class="info(e.key).laeuft ? 'blau' : info(e.key).fertig ? 'gruen' : ''">
|
||||
{{ info(e.key).laeuft ? 'läuft' : info(e.key).fertig ? 'fertig' : 'offen' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="zeile">
|
||||
<span class="badge">{{ k(info(e.key).tokens || 0) }} Tok</span>
|
||||
<span class="badge">{{ dauer(ebenenDauer(e.key)) }}</span>
|
||||
<label><input type="checkbox" :checked="info(e.key).auto"
|
||||
@change="autoSetzen(e.key, $event)" /> Auto</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="liste">
|
||||
<div v-for="kt in karten(e.key)" :key="kt.id" class="karte">
|
||||
{{ kt.text }}
|
||||
<div class="meta">
|
||||
<span v-for="([txt, farbe], i) in kt.badges" :key="i"
|
||||
class="badge" :class="farbe">{{ txt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="menue" style="position: fixed; inset: 0; z-index: 99"
|
||||
@click="menue = null" @contextmenu.prevent="menue = null">
|
||||
<div class="kontextmenue" :style="{ left: menue.x + 'px', top: menue.y + 'px' }">
|
||||
<button :disabled="!generierenErlaubt(menue.idx)" @click.stop="menueGenerieren">
|
||||
Generieren
|
||||
</button>
|
||||
<button class="rot" @click.stop="menueEntfernen">
|
||||
„{{ menue.titel }}" + folgende Ebenen entfernen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="seite">Kein Thema gewählt — links anlegen.</div>
|
||||
</template>
|
||||
159
frontend/src/components/Guide.vue
Normal file
159
frontend/src/components/Guide.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import { api } from '../api.js'
|
||||
import { render, gefiltert, lesestat, renderGestuft } from '../markdown.js'
|
||||
|
||||
const props = defineProps({ topic: String, state: Object })
|
||||
const daten = ref(null)
|
||||
const level = ref('E') // Default: Einstieg — Umfang wächst per Toggle (E→M→S)
|
||||
const ansicht = ref('erklaerend') // erklaerend = Fließtext | kompakt = Stichpunkte
|
||||
const LEVEL_ICON = { E: '●○○', M: '●●○', S: '●●●' }
|
||||
const LEVEL_NAME = { E: 'Umfang: nur Einstieg', M: 'Umfang: bis Mittel', S: 'Umfang: alles' }
|
||||
const fokus = ref(false) // Fokus: Vollbild — App-Chrome + Navigation weg, Karten aufgelöst
|
||||
watch(fokus, (f) => document.body.classList.toggle('vollbild', f))
|
||||
onUnmounted(() => document.body.classList.remove('vollbild'))
|
||||
const aktiv = ref(0)
|
||||
const kapEls = []
|
||||
|
||||
async function laden() {
|
||||
if (!props.topic) return
|
||||
daten.value = await api.guide(props.topic)
|
||||
}
|
||||
watch(() => props.topic, laden, { immediate: true })
|
||||
watch(() => props.state?.run?.status, laden) // nach Lauf-Ende neu ziehen
|
||||
|
||||
// Markdown+KaTeX sind teuer (66 Sections, ~3.400 Formeln): einmal rendern,
|
||||
// dann aus dem Cache — sonst rechnet jeder Scroll-Tick alles neu (~600 ms).
|
||||
const htmlCache = new Map()
|
||||
watch(daten, () => htmlCache.clear())
|
||||
|
||||
function html(s) {
|
||||
const key = `${ansicht.value}|${level.value}|${s.baustein}`
|
||||
if (!htmlCache.has(key)) {
|
||||
htmlCache.set(key, ansicht.value === 'kompakt'
|
||||
? render(s.kompakt || s.lang) : renderGestuft(s.lang, level.value))
|
||||
}
|
||||
return htmlCache.get(key)
|
||||
}
|
||||
|
||||
// Nur aktives Kapitel ±1 im DOM; der Rest ist ein Platzhalter mit Schätzhöhe
|
||||
const sichtbar = (i) => Math.abs(i - aktiv.value) <= 1
|
||||
|
||||
function hoehe(i) {
|
||||
const st = stats.value.je[i]
|
||||
const sections = daten.value?.kapitel?.[i]?.sections?.length || 1
|
||||
return Math.round((st?.woerter || 200) / 11 * 28 + sections * 180)
|
||||
}
|
||||
|
||||
function setAnsicht(a) {
|
||||
ansicht.value = a
|
||||
laden()
|
||||
}
|
||||
|
||||
function levelToggle() {
|
||||
const folge = ['E', 'M', 'S']
|
||||
level.value = folge[(folge.indexOf(level.value) + 1) % 3]
|
||||
laden()
|
||||
}
|
||||
|
||||
// Lesezeit des SICHTBAREN Umfangs: 150 Wörter/min (technischer Text)
|
||||
// + 5 s je Display-Formel (Medium ~265 wpm gilt nicht für Mathe)
|
||||
const stats = computed(() => {
|
||||
const je = (daten.value?.kapitel || []).map((kap) => {
|
||||
let woerter = ansicht.value === 'kompakt' ? 0 : lesestat(kap.intro).woerter
|
||||
let formeln = 0
|
||||
for (const s of kap.sections) {
|
||||
const st = lesestat(ansicht.value === 'kompakt' ? (s.kompakt || s.lang)
|
||||
: gefiltert(s.lang, level.value))
|
||||
woerter += st.woerter
|
||||
formeln += st.displayFormeln
|
||||
}
|
||||
return { woerter, minuten: woerter / 150 + (formeln * 5) / 60 }
|
||||
})
|
||||
return {
|
||||
je,
|
||||
woerter: je.reduce((a, k) => a + k.woerter, 0),
|
||||
minuten: je.reduce((a, k) => a + k.minuten, 0),
|
||||
}
|
||||
})
|
||||
|
||||
function zeit(min) {
|
||||
const m = Math.max(1, Math.round(min))
|
||||
return m < 60 ? `~${m} min` : `~${Math.floor(m / 60)} h ${m % 60} min`
|
||||
}
|
||||
|
||||
function setKapEl(i, el) {
|
||||
if (el) kapEls[i] = el
|
||||
}
|
||||
|
||||
async function springe(i) {
|
||||
aktiv.value = i // erst rendern (Platzhalter → Inhalt), dann springen
|
||||
await nextTick()
|
||||
kapEls[i]?.scrollIntoView({ block: 'start' })
|
||||
}
|
||||
|
||||
function onScroll(e) {
|
||||
const top = e.target.scrollTop + 130
|
||||
let a = 0
|
||||
kapEls.forEach((el, i) => { if (el && el.offsetTop <= top) a = i })
|
||||
aktiv.value = a
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="guide-layout" :class="{ fokus }">
|
||||
<nav v-if="daten?.kapitel?.length" class="guide-nav">
|
||||
<div class="schalter-zeile">
|
||||
<div class="schalter">
|
||||
<button :class="{ aktiv: ansicht === 'erklaerend' }" title="ausführlich (viel Text)"
|
||||
@click="setAnsicht('erklaerend')">
|
||||
<svg width="14" height="12" viewBox="0 0 14 12">
|
||||
<path d="M0 1h14M0 3.5h14M0 6h14M0 8.5h14M0 11h9"
|
||||
stroke="currentColor" stroke-width="1.4" fill="none" />
|
||||
</svg>
|
||||
</button>
|
||||
<button :class="{ aktiv: ansicht === 'kompakt' }" title="kompakt (wenig Text)"
|
||||
@click="setAnsicht('kompakt')">
|
||||
<svg width="14" height="12" viewBox="0 0 14 12">
|
||||
<path d="M0 2.5h14M0 6h8" stroke="currentColor" stroke-width="1.4" fill="none" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button class="schalter-solo" :title="LEVEL_NAME[level]" @click="levelToggle">
|
||||
{{ LEVEL_ICON[level] }}
|
||||
</button>
|
||||
<button class="schalter-solo" title="Fokus-Modus (ablenkungsfrei lesen)"
|
||||
@click="fokus = true">⛶</button>
|
||||
</div>
|
||||
<div class="fortschritt">
|
||||
<div>Kapitel {{ aktiv + 1 }} / {{ daten.kapitel.length }}</div>
|
||||
<div>{{ stats.woerter.toLocaleString('de-DE') }} Wörter · {{ zeit(stats.minuten) }}</div>
|
||||
</div>
|
||||
<a v-for="(kap, i) in daten.kapitel" :key="i" :class="{ aktiv: i === aktiv }"
|
||||
@click="springe(i)">
|
||||
<span>{{ i + 1 }}. {{ kap.titel }}</span>
|
||||
<span class="zeit">{{ zeit(stats.je[i]?.minuten || 0) }}</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="seite guide-text" @scroll="onScroll">
|
||||
<button v-if="fokus" class="fokus-aus" title="Fokus beenden"
|
||||
@click="fokus = false">✕</button>
|
||||
<template v-if="daten">
|
||||
<div v-for="(kap, i) in daten.kapitel" :key="i" class="kapitel"
|
||||
:ref="(el) => setKapEl(i, el)">
|
||||
<h2 class="kapitel-titel">{{ i + 1 }}. {{ kap.titel }}</h2>
|
||||
<template v-if="sichtbar(i)">
|
||||
<p v-if="kap.intro && ansicht === 'erklaerend'"
|
||||
style="color: var(--dim); margin-top: 0">{{ kap.intro }}</p>
|
||||
<div v-for="s in kap.sections" :key="s.baustein" class="guide-section">
|
||||
<h3 style="margin-top: 0">{{ s.titel }}</h3>
|
||||
<div :class="ansicht === 'kompakt' ? 'kompakt' : 'markdown'" v-html="html(s)"></div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="kapitel-platzhalter" :style="{ height: hoehe(i) + 'px' }"></div>
|
||||
</div>
|
||||
<div v-if="!daten.kapitel.length" class="guide-section">Noch kein Guide generiert.</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
50
frontend/src/components/Kennzahlen.vue
Normal file
50
frontend/src/components/Kennzahlen.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { api } from '../api.js'
|
||||
|
||||
const props = defineProps({ run: Object })
|
||||
const daten = ref(null)
|
||||
|
||||
async function laden() {
|
||||
if (!props.run) return
|
||||
daten.value = await api.kennzahlen(props.run.id)
|
||||
}
|
||||
watch(() => props.run?.id, laden, { immediate: true })
|
||||
|
||||
const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : n)
|
||||
const s = (ms) => `${(ms / 1000).toFixed(0)}s`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="seite">
|
||||
<div style="display: flex; gap: 10px; align-items: center; margin-bottom: 12px">
|
||||
<h2 style="margin: 0; flex: 1">Kennzahlen {{ run ? `(Lauf ${run.id})` : '' }}</h2>
|
||||
<span v-if="daten" class="badge">{{ k(daten.verbraucht) }} Tokens gesamt</span>
|
||||
<button class="sekundaer" @click="laden">Neu laden</button>
|
||||
</div>
|
||||
<table v-if="daten" class="kennzahlen">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ebene / Stage / Template</th><th>Calls</th><th>ok</th><th>Timeout</th>
|
||||
<th>Fehler</th><th>Tok in</th><th>Tok out</th><th>Cache</th>
|
||||
<th>Dauer</th><th>Wartezeit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="z in daten.zeilen" :key="z.ebene + z.stage + z.template">
|
||||
<td>{{ z.ebene }} / {{ z.stage }} <span style="color: var(--dim)">{{ z.template }}</span></td>
|
||||
<td>{{ z.calls }}</td>
|
||||
<td>{{ z.ok }}</td>
|
||||
<td :style="z.timeouts ? 'color: var(--gelb)' : ''">{{ z.timeouts }}</td>
|
||||
<td :style="z.fehler ? 'color: var(--rot)' : ''">{{ z.fehler }}</td>
|
||||
<td>{{ k(z.tok_in || 0) }}</td>
|
||||
<td>{{ k(z.tok_out || 0) }}</td>
|
||||
<td>{{ k(z.cache_read || 0) }}</td>
|
||||
<td>{{ s(z.dur_ms || 0) }}</td>
|
||||
<td>{{ s(z.wait_ms || 0) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else style="color: var(--dim)">Noch kein Lauf.</p>
|
||||
</div>
|
||||
</template>
|
||||
49
frontend/src/components/Ueben.vue
Normal file
49
frontend/src/components/Ueben.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { api } from '../api.js'
|
||||
import { render } from '../markdown.js'
|
||||
|
||||
const props = defineProps({ topic: String })
|
||||
const stapel = ref([])
|
||||
const zeigeAntwort = ref(false)
|
||||
const fertigGelernt = ref(0)
|
||||
|
||||
async function laden() {
|
||||
if (!props.topic) return
|
||||
stapel.value = await api.ueben(props.topic)
|
||||
zeigeAntwort.value = false
|
||||
fertigGelernt.value = 0
|
||||
}
|
||||
watch(() => props.topic, laden, { immediate: true })
|
||||
|
||||
async function antworten(richtig) {
|
||||
const karte = stapel.value[0]
|
||||
await api.antwort(karte.id, richtig)
|
||||
stapel.value = richtig ? stapel.value.slice(1) : [...stapel.value.slice(1), karte]
|
||||
if (richtig) fertigGelernt.value++
|
||||
zeigeAntwort.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="seite">
|
||||
<div v-if="stapel.length" class="flash">
|
||||
<div class="meta" style="color: var(--dim); margin-bottom: 8px">
|
||||
{{ stapel[0].titel }} · Box {{ stapel[0].box }} · noch {{ stapel.length }} fällig
|
||||
</div>
|
||||
<div class="frage" v-html="render(stapel[0].inhalt.frage)"></div>
|
||||
<button v-if="!zeigeAntwort" @click="zeigeAntwort = true">Antwort zeigen</button>
|
||||
<template v-else>
|
||||
<div class="antwort markdown" v-html="render(stapel[0].inhalt.antwort)"></div>
|
||||
<div style="display: flex; gap: 10px; justify-content: center">
|
||||
<button style="background: var(--gruen)" @click="antworten(true)">Richtig</button>
|
||||
<button style="background: var(--rot)" @click="antworten(false)">Falsch</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="flash">
|
||||
<p>Keine fälligen Karten. {{ fertigGelernt ? `${fertigGelernt} gelernt — stark.` : '' }}</p>
|
||||
<button class="sekundaer" @click="laden">Neu laden</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
6
frontend/src/main.js
Normal file
6
frontend/src/main.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import '@fontsource-variable/inter'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
102
frontend/src/markdown.js
Normal file
102
frontend/src/markdown.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Marked } from 'marked'
|
||||
import { markedHighlight } from 'marked-highlight'
|
||||
import hljs from 'highlight.js'
|
||||
import katex from 'katex'
|
||||
import DOMPurify from 'dompurify'
|
||||
import 'highlight.js/styles/github-dark.css'
|
||||
import 'katex/dist/katex.min.css'
|
||||
|
||||
const marked = new Marked(
|
||||
markedHighlight({
|
||||
langPrefix: 'hljs language-',
|
||||
highlight(code, lang) {
|
||||
const l = hljs.getLanguage(lang) ? lang : 'plaintext'
|
||||
return hljs.highlight(code, { language: l }).value
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
function kx(tex, displayMode) {
|
||||
// Newlines im KaTeX-HTML falten (\sqrt-SVG ist mehrzeilig): in Markdown-
|
||||
// Überschriften endet der Block sonst an der Zeile und das SVG zerreißt
|
||||
return katex.renderToString(tex, { displayMode }).replace(/\n/g, ' ')
|
||||
}
|
||||
|
||||
function mathe(text) {
|
||||
// $$…$$/\\[…\\] (Display) und $…$/\\(…\\) (Inline) vor dem Markdown-Pass rendern
|
||||
return text
|
||||
.replace(/\$\$([\s\S]+?)\$\$/g, (_, tex) => {
|
||||
try { return kx(tex, true) } catch { return _ }
|
||||
})
|
||||
.replace(/\\\[([\s\S]+?)\\\]/g, (_, tex) => {
|
||||
try { return kx(tex, true) } catch { return _ }
|
||||
})
|
||||
.replace(/\$([^$\n]+?)\$/g, (_, tex) => {
|
||||
try { return kx(tex, false) } catch { return _ }
|
||||
})
|
||||
.replace(/\\\(([\s\S]+?)\\\)/g, (_, tex) => {
|
||||
try { return kx(tex, false) } catch { return _ }
|
||||
})
|
||||
}
|
||||
|
||||
export function render(text) {
|
||||
return DOMPurify.sanitize(marked.parse(mathe(text || '')))
|
||||
}
|
||||
|
||||
const RANG = { E: 0, M: 1, S: 2 }
|
||||
const MARKER = /<!--\s*atom:\s*(\d+)\s*\|\s*([^|]*)\|\s*([EMS])\s*-->/g
|
||||
|
||||
// Wortzahl + Display-Formeln des sichtbaren Texts (für die Lesezeit-Schätzung)
|
||||
export function lesestat(text) {
|
||||
let displayFormeln = 0
|
||||
const t = (text || '')
|
||||
.replace(MARKER, ' ')
|
||||
.replace(/\$\$[\s\S]+?\$\$/g, () => { displayFormeln += 1; return ' ' })
|
||||
.replace(/\$[^$\n]+?\$/g, ' Formel ')
|
||||
const woerter = t.split(/\s+/).filter(Boolean).length
|
||||
return { woerter, displayFormeln }
|
||||
}
|
||||
|
||||
// Text an Atom-Markern in Segmente schneiden; Filter zeigt Atome bis zum gewählten Level.
|
||||
export function segmente(lang) {
|
||||
const out = []
|
||||
let letzt = 0
|
||||
let aktuell = { level: null, titel: '', text: '' }
|
||||
for (const m of lang.matchAll(MARKER)) {
|
||||
aktuell.text = lang.slice(letzt, m.index)
|
||||
out.push(aktuell)
|
||||
aktuell = { level: m[3], titel: m[2].trim(), text: '' }
|
||||
letzt = m.index + m[0].length
|
||||
}
|
||||
aktuell.text = lang.slice(letzt)
|
||||
out.push(aktuell)
|
||||
return out
|
||||
}
|
||||
|
||||
export function gefiltert(lang, maxLevel) {
|
||||
if (!maxLevel || maxLevel === 'S') return lang
|
||||
return segmente(lang)
|
||||
.filter((s) => s.level === null || RANG[s.level] <= RANG[maxLevel])
|
||||
.map((s) => s.text)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
// Gestuftes Rendern: Segmente über dem Level fallen weg, Segmente DARUNTER
|
||||
// werden gedimmt (schon gelernt) — der Blick geht auf die neuen Inhalte.
|
||||
// Kernpunkte/Prüfe-dich (nach dem letzten Atom) bleiben immer ungedimmt.
|
||||
export function renderGestuft(lang, maxLevel) {
|
||||
if (!maxLevel || maxLevel === 'E') return render(gefiltert(lang, maxLevel))
|
||||
const seg = segmente(lang)
|
||||
const letzt = seg[seg.length - 1]
|
||||
const i = letzt.text.indexOf('**Kernpunkte:**')
|
||||
if (i >= 0) {
|
||||
seg.push({ level: null, titel: '', text: letzt.text.slice(i) })
|
||||
letzt.text = letzt.text.slice(0, i)
|
||||
}
|
||||
return seg.map((s) => {
|
||||
if (s.level !== null && RANG[s.level] > RANG[maxLevel]) return ''
|
||||
const html = render(s.text)
|
||||
const gelernt = s.level !== null && RANG[s.level] < RANG[maxLevel]
|
||||
return gelernt ? `<div class="gelernt">${html}</div>` : html
|
||||
}).join('')
|
||||
}
|
||||
243
frontend/src/style.css
Normal file
243
frontend/src/style.css
Normal file
@@ -0,0 +1,243 @@
|
||||
:root {
|
||||
--bg: #10141a;
|
||||
--panel: #171d26;
|
||||
--karte: #1f2733;
|
||||
--rand: #2c3646;
|
||||
--text: #dce3ec;
|
||||
--dim: #8b98a9;
|
||||
--akzent: #4da3ff;
|
||||
--gruen: #3fc380;
|
||||
--gelb: #e8c34a;
|
||||
--rot: #e06060;
|
||||
--badge-gruen-bg: #1d3a2c;
|
||||
--badge-gelb-bg: #3a331d;
|
||||
--badge-rot-bg: #3a1d1d;
|
||||
--badge-blau-bg: #1d2b3a;
|
||||
--code-bg: #0d1117;
|
||||
}
|
||||
:root { --lese-text: #c9d2df; } /* Guide-Fließtext: weicher als --text */
|
||||
:root[data-theme="light"] { --lese-text: #333747; }
|
||||
:root[data-theme="sepia"] {
|
||||
--bg: #f0e7d3;
|
||||
--panel: #f9f2df;
|
||||
--karte: #efe4c9;
|
||||
--rand: #d9cba8;
|
||||
--text: #443b2c;
|
||||
--dim: #83765c;
|
||||
--akzent: #7a5c2e;
|
||||
--gruen: #4f7a42;
|
||||
--gelb: #8f6b14;
|
||||
--rot: #a6442e;
|
||||
--badge-gruen-bg: #e2ecd5;
|
||||
--badge-gelb-bg: #f0e6c2;
|
||||
--badge-rot-bg: #f0d8cd;
|
||||
--badge-blau-bg: #e8e0c8;
|
||||
--code-bg: #ede3ca;
|
||||
--lese-text: #443b2c;
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
--bg: #f3f5f8;
|
||||
--panel: #ffffff;
|
||||
--karte: #eef1f5;
|
||||
--rand: #d4dae2;
|
||||
--text: #1c2430;
|
||||
--dim: #5c6a7c;
|
||||
--akzent: #1a73d6;
|
||||
--gruen: #1d8a55;
|
||||
--gelb: #9a7b12;
|
||||
--rot: #c23b3b;
|
||||
--badge-gruen-bg: #dcf2e6;
|
||||
--badge-gelb-bg: #f6eecb;
|
||||
--badge-rot-bg: #f6d9d9;
|
||||
--badge-blau-bg: #dcebfa;
|
||||
--code-bg: #f0f2f6;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.5 system-ui, sans-serif;
|
||||
}
|
||||
#app { height: 100vh; display: flex; }
|
||||
button {
|
||||
background: var(--akzent); color: #fff; border: 0; border-radius: 6px;
|
||||
padding: 6px 14px; cursor: pointer; font: inherit;
|
||||
}
|
||||
button.sekundaer { background: var(--karte); color: var(--text); border: 1px solid var(--rand); }
|
||||
button.klein { padding: 2px 8px; font-size: 12px; }
|
||||
button:disabled { opacity: 0.4; cursor: default; }
|
||||
input, select {
|
||||
background: var(--karte); color: var(--text); border: 1px solid var(--rand);
|
||||
border-radius: 6px; padding: 6px 10px; font: inherit;
|
||||
}
|
||||
|
||||
/* Sidebar-Navigation */
|
||||
.sidebar {
|
||||
width: 220px; min-width: 220px; background: var(--panel);
|
||||
border-right: 1px solid var(--rand); display: flex; flex-direction: column;
|
||||
}
|
||||
.sidebar .topics { flex: 1; overflow-y: auto; padding: 8px; }
|
||||
.sidebar .topic-eintrag {
|
||||
display: flex; align-items: center; gap: 6px; padding: 7px 10px;
|
||||
border-radius: 6px; cursor: pointer; color: var(--dim);
|
||||
}
|
||||
.sidebar .topic-eintrag.aktiv { background: var(--karte); color: var(--text); }
|
||||
.sidebar .neu { padding: 10px; border-top: 1px solid var(--rand); display: grid; gap: 6px; }
|
||||
.kontextmenue {
|
||||
position: fixed; background: var(--panel); border: 1px solid var(--rand);
|
||||
border-radius: 8px; padding: 4px; box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.kontextmenue button {
|
||||
display: block; width: 100%; text-align: left; background: none;
|
||||
color: var(--text); border: 0; padding: 7px 12px; border-radius: 6px;
|
||||
}
|
||||
.kontextmenue button:hover:not(:disabled) { background: var(--karte); }
|
||||
.kontextmenue button.rot { color: var(--rot); }
|
||||
|
||||
.hauptbereich { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.topbar {
|
||||
display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
|
||||
padding: 8px 16px; background: var(--panel); border-bottom: 1px solid var(--rand);
|
||||
}
|
||||
.tabs { display: flex; gap: 4px; }
|
||||
.tabs button { background: transparent; color: var(--dim); }
|
||||
.tabs button.aktiv { background: var(--karte); color: var(--text); }
|
||||
.inhalt { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.badge {
|
||||
display: inline-block; padding: 1px 8px; border-radius: 10px;
|
||||
font-size: 11px; background: var(--karte); color: var(--dim);
|
||||
}
|
||||
.badge.gruen { background: var(--badge-gruen-bg); color: var(--gruen); }
|
||||
.badge.gelb { background: var(--badge-gelb-bg); color: var(--gelb); }
|
||||
.badge.rot { background: var(--badge-rot-bg); color: var(--rot); }
|
||||
.badge.blau { background: var(--badge-blau-bg); color: var(--akzent); }
|
||||
|
||||
/* Befunde/Agenten-Leiste über dem Board */
|
||||
.leiste {
|
||||
display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
|
||||
padding: 8px 12px; border-bottom: 1px solid var(--rand); background: var(--panel);
|
||||
min-height: 40px; max-height: 130px; overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Board: Ebenen-Spalten links→rechts, Listen VOLLSTÄNDIG scrollbar */
|
||||
.board { display: flex; gap: 10px; padding: 12px; overflow-x: auto; flex: 1; min-height: 0; }
|
||||
.spalte {
|
||||
min-width: 265px; max-width: 320px; flex: 1; display: flex; flex-direction: column;
|
||||
background: var(--panel); border: 1px solid var(--rand); border-radius: 8px;
|
||||
}
|
||||
.spaltekopf { padding: 8px 10px; border-bottom: 1px solid var(--rand); display: grid; gap: 6px; }
|
||||
.spaltekopf .zeile { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||
.spaltekopf .titel { font-weight: 600; font-size: 13px; flex: 1; }
|
||||
.spaltekopf label { font-size: 12px; color: var(--dim); display: flex; gap: 4px; align-items: center; }
|
||||
.spalte .liste { overflow-y: auto; flex: 1; padding: 8px; }
|
||||
.karte {
|
||||
background: var(--karte); border: 1px solid var(--rand); border-radius: 6px;
|
||||
padding: 6px 9px; margin-bottom: 6px; font-size: 13px;
|
||||
}
|
||||
.karte .meta { color: var(--dim); font-size: 11px; margin-top: 2px; }
|
||||
|
||||
.seite { flex: 1; overflow-y: auto; padding: 20px; }
|
||||
.seite.schmal { max-width: 900px; margin: 0 auto; }
|
||||
|
||||
/* Guide: Kapitel-Navigation + Lesetypografie (~70 Zeichen/Zeile, 17px) */
|
||||
.guide-layout { flex: 1; display: flex; overflow: hidden; min-height: 0; }
|
||||
.guide-nav {
|
||||
width: 260px; min-width: 220px; overflow-y: auto; padding: 14px 10px;
|
||||
border-right: 1px solid var(--rand); background: var(--panel); font-size: 13px;
|
||||
}
|
||||
.guide-nav .schalter-zeile {
|
||||
display: flex; gap: 6px; align-items: center; padding: 2px 4px 10px;
|
||||
}
|
||||
.schalter { display: flex; border: 1px solid var(--rand); border-radius: 6px; overflow: hidden; }
|
||||
.schalter button {
|
||||
background: none; color: var(--dim); border: 0; border-radius: 0;
|
||||
padding: 4px 9px; font-size: 12px; line-height: 1;
|
||||
display: flex; align-items: center;
|
||||
}
|
||||
.schalter button.aktiv { background: var(--karte); color: var(--text); }
|
||||
.schalter-solo {
|
||||
background: none; color: var(--dim); border: 1px solid var(--rand);
|
||||
border-radius: 6px; padding: 3px 9px; font-size: 12px;
|
||||
}
|
||||
.guide-nav .fortschritt {
|
||||
color: var(--dim); font-size: 12px; padding: 0 8px 10px;
|
||||
border-bottom: 1px solid var(--rand); margin-bottom: 8px;
|
||||
}
|
||||
.guide-nav a {
|
||||
display: flex; justify-content: space-between; gap: 8px; align-items: baseline;
|
||||
padding: 5px 8px; border-radius: 6px;
|
||||
color: var(--dim); cursor: pointer; line-height: 1.35;
|
||||
}
|
||||
.guide-nav a .zeit { font-size: 11px; white-space: nowrap; opacity: 0.75; }
|
||||
.guide-nav a:hover { color: var(--text); }
|
||||
.guide-nav a.aktiv { background: var(--karte); color: var(--text); }
|
||||
.seite.guide-text {
|
||||
position: relative; max-width: 680px; margin: 0 auto;
|
||||
scrollbar-width: none; /* Scrollbalken unsichtbar, Scrollen bleibt */
|
||||
font-family: 'Inter Variable', system-ui, sans-serif;
|
||||
font-size: 19px; line-height: 1.55; color: var(--lese-text);
|
||||
hyphens: auto; -webkit-hyphens: auto;
|
||||
}
|
||||
/* Fokus-Modus: Navigation weg, Karten-Rahmen aufgelöst — reiner Artikel-Fluss */
|
||||
.guide-layout.fokus .guide-nav { display: none; }
|
||||
.guide-layout.fokus .guide-section {
|
||||
border: 0; background: none; padding: 0 0 6px; margin-bottom: 4px;
|
||||
}
|
||||
body.vollbild .sidebar, body.vollbild .topbar { display: none; }
|
||||
body.vollbild .nav-toggle { display: none; }
|
||||
/* Eingeklappt: Themen-Sidebar + Topbar weg — die Kapitelübersicht bleibt */
|
||||
body.nav-zu .sidebar, body.nav-zu .topbar { display: none; }
|
||||
body.nav-zu .guide-nav { padding-top: 44px; }
|
||||
.nav-toggle {
|
||||
position: fixed; top: 6px; left: 8px; z-index: 20;
|
||||
background: none; color: var(--dim); border: 0; padding: 4px 8px; font-size: 16px;
|
||||
}
|
||||
.nav-toggle:hover { color: var(--text); }
|
||||
.sidebar .topics { padding-top: 40px; } /* Platz für das fixe ☰ links oben */
|
||||
.fokus-aus {
|
||||
position: fixed; top: 10px; right: 16px; z-index: 5;
|
||||
background: none; color: var(--dim); border: 0;
|
||||
padding: 4px 8px; font-size: 16px; opacity: 0.6;
|
||||
}
|
||||
.fokus-aus:hover { opacity: 1; }
|
||||
.seite.guide-text::-webkit-scrollbar { display: none; }
|
||||
.markdown .gelernt { opacity: 0.45; } /* niedrigere Level = schon gelernt */
|
||||
.guide-text .kapitel { scroll-margin-top: 8px; }
|
||||
.guide-text .kapitel-titel { margin: 30px 0 6px; }
|
||||
.kapitel-platzhalter { border-left: 2px dashed var(--rand); margin: 8px 0 8px 4px; }
|
||||
/* Kompakt-Ansicht: gleiche Lesetypografie wie der Fließtext, kein Grau-Kasten */
|
||||
.guide-text .kompakt {
|
||||
background: none; padding: 0; color: var(--lese-text);
|
||||
font-size: 17px; line-height: 1.55;
|
||||
}
|
||||
.guide-text .kompakt li { margin: 5px 0; }
|
||||
.guide-section {
|
||||
background: var(--panel); border: 1px solid var(--rand); border-radius: 8px;
|
||||
padding: 16px 22px; margin-bottom: 16px;
|
||||
}
|
||||
.guide-section .ziel { color: var(--akzent); font-size: 13px; }
|
||||
.markdown .katex-display { overflow-x: auto; overflow-y: hidden; padding: 4px 0; }
|
||||
.markdown blockquote {
|
||||
margin: 12px 0; padding: 8px 14px; border-left: 3px solid var(--akzent);
|
||||
background: var(--karte); border-radius: 0 6px 6px 0; color: var(--text);
|
||||
}
|
||||
.markdown blockquote p { margin: 4px 0; }
|
||||
.markdown :is(code):not(.hljs) { background: var(--karte); padding: 1px 5px; border-radius: 4px; }
|
||||
.markdown pre { background: var(--code-bg); padding: 10px; border-radius: 6px; overflow-x: auto; }
|
||||
.markdown table { border-collapse: collapse; }
|
||||
.markdown td, .markdown th { border: 1px solid var(--rand); padding: 4px 10px; }
|
||||
|
||||
.flash {
|
||||
background: var(--panel); border: 1px solid var(--rand); border-radius: 10px;
|
||||
padding: 28px; max-width: 620px; margin: 40px auto; text-align: center;
|
||||
}
|
||||
.flash .frage { font-size: 18px; margin-bottom: 18px; }
|
||||
.flash .antwort { background: var(--karte); border-radius: 8px; padding: 14px; margin: 14px 0; }
|
||||
|
||||
table.kennzahlen { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||
table.kennzahlen th, table.kennzahlen td {
|
||||
border-bottom: 1px solid var(--rand); padding: 5px 10px; text-align: right;
|
||||
}
|
||||
table.kennzahlen th:first-child, table.kennzahlen td:first-child { text-align: left; }
|
||||
table.kennzahlen th { color: var(--dim); }
|
||||
Reference in New Issue
Block a user