This commit is contained in:
Team3
2026-09-27 21:36:33 +02:00
commit 498fe2220a
34 changed files with 3608 additions and 0 deletions

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>buchwerk</title>
<link rel="icon" href="data:," />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

133
frontend/mock/server.js Normal file
View File

@@ -0,0 +1,133 @@
// Kleiner statischer Mock des Backends laut API.md für den Sichttest: node mock/server.js (Port 8020, PORT überschreibt)
import http from 'node:http'
const PORT = Number(process.env.PORT || 8020)
const jetzt = Date.now()
const iso = (minVor) => new Date(jetzt - minVor * 60000).toISOString()
const topics = [
{ name: 'Godot 4', titel: 'Godot 4', status: 'laeuft', laeuft: true, stufe: 'schreiben' },
{ name: 'Symfony 7', titel: 'Symfony 7', status: 'neu', laeuft: false, stufe: '' },
]
const bericht = `## Ergebnis
Kapitel 3 geschrieben, 4 Abschnitte, 6 900 Wörter.
- Quellen: \`quellen/godot-docs/tutorials/scripting/gdscript/\`
- Offen: Abschnitt 3.4 braucht ein Beispiel zu \`await\`
| Abschnitt | Wörter |
|---|---|
| 3.1 | 1400 |
| 3.2 | 1900 |
`
const state = {
name: 'Godot 4', titel: 'Godot 4', hinweise: 'für Entwickler, ab Version 4.6',
status: 'laeuft', grund: '', stufe: 'schreiben',
deutung: { titel: 'Godot 4', untertitel: 'Architektur, Konzepte und Praxis der Engine', buchtyp: 'werkzeug',
zielgruppe: 'Entwickler mit Programmiererfahrung, neu in Godot', referenz: 'Godot 4.6, offizielle Dokumentation',
deutung: 'Ein Werkzeugbuch: erklärt die Engine entlang ihrer Konzepte (Szenenbaum, Signale, GDScript) und zeigt jeweils die Praxis.' },
stufen: [
{ id: 'recherche', titel: 'Thema deuten und Quellen beschaffen', status: 'fertig', fertig: 1, gesamt: 1, kosten_usd: 1.2, tokens: 250000, gestartet: iso(60), beendet: iso(52) },
{ id: 'gliederung', titel: 'Gliederung', status: 'fertig', fertig: 1, gesamt: 1, kosten_usd: 0.8, tokens: 120000, gestartet: iso(52), beendet: iso(45) },
{ id: 'schreiben', titel: 'Kapitel schreiben', status: 'laeuft', fertig: 3, gesamt: 18, kosten_usd: 4.1, tokens: 1100000, gestartet: iso(45), beendet: '' },
{ id: 'pruefung', titel: 'Kapitel prüfen', status: 'laeuft', fertig: 1, gesamt: 18, kosten_usd: 0.9, tokens: 300000, gestartet: iso(30), beendet: '' },
{ id: 'konsistenz', titel: 'Querverweise und Vorwort', status: 'offen', fertig: 0, gesamt: 1, kosten_usd: 0, tokens: 0, gestartet: '', beendet: '' },
{ id: 'montage', titel: 'Buch zusammensetzen', status: 'offen', fertig: 0, gesamt: 1, kosten_usd: 0, tokens: 0, gestartet: '', beendet: '' },
],
kosten_usd: 7.0, tokens: 1770000,
agenten: [
{ id: 'recherche', stufe: 'recherche', name: 'Recherche', status: 'fertig', kosten_usd: 1.2, tokens: 250000, werkzeuge: 58, gestartet: iso(60), beendet: iso(52), bericht: '## Quellen\n\n12 Quellen geladen, davon 9 brauchbar.' },
{ id: 'gliederung', stufe: 'gliederung', name: 'Gliederung', status: 'fertig', kosten_usd: 0.8, tokens: 120000, werkzeuge: 14, gestartet: iso(52), beendet: iso(45), bericht: 'Gliederung mit 18 Kapiteln in 4 Teilen.' },
{ id: 'schreiben-01', stufe: 'schreiben', name: 'Kapitel 1 schreiben', status: 'fertig', kosten_usd: 0.9, tokens: 180000, werkzeuge: 41, gestartet: iso(45), beendet: iso(38), bericht },
{ id: 'schreiben-02', stufe: 'schreiben', name: 'Kapitel 2 schreiben', status: 'fehler', kosten_usd: 0.3, tokens: 60000, werkzeuge: 9, gestartet: iso(45), beendet: iso(42), bericht: '' },
{ id: 'schreiben-03', stufe: 'schreiben', name: 'Kapitel 3 schreiben', status: 'fertig', kosten_usd: 1.1, tokens: 220000, werkzeuge: 52, gestartet: iso(38), beendet: iso(30), bericht },
{ id: 'schreiben-04', stufe: 'schreiben', name: 'Kapitel 4 schreiben', status: 'laeuft', kosten_usd: 0.4, tokens: 90000, werkzeuge: 17, gestartet: iso(8), beendet: '', bericht: '' },
{ id: 'pruefung-01', stufe: 'pruefung', name: 'Kapitel 1 prüfen', status: 'fertig', kosten_usd: 0.5, tokens: 150000, werkzeuge: 22, gestartet: iso(30), beendet: iso(20), bericht: '## Prüfung\n\nAlle Aussagen belegt. Zwei Formulierungen korrigiert.' },
{ id: 'pruefung-03', stufe: 'pruefung', name: 'Kapitel 3 prüfen', status: 'laeuft', kosten_usd: 0.4, tokens: 150000, werkzeuge: 30, gestartet: iso(12), beendet: '', bericht: '' },
],
ereignisse: Array.from({ length: 100 }, (_, i) => ({
zeit: iso(100 - i), agent: i % 3 ? 'schreiben-04' : 'pruefung-03',
art: ['tool', 'text', 'start', 'ende', 'fehler'][i % 5],
text: i % 5 === 0 ? 'Write buch/04-szenen.md' : i % 5 === 1 ? 'Ich lese jetzt die Dokumentation zum Szenenbaum und gleiche die Signale ab.' : `Ereignis ${i}`,
})),
}
const text = (n) => Array.from({ length: n }, (_, i) => `Absatz ${i + 1}: Der Szenenbaum ist die zentrale Datenstruktur von Godot. Jeder Knoten hat genau einen Elternknoten, und Signale verbinden Knoten lose miteinander.`).join('\n\n')
const code = '\n\n```gdscript\nextends Node2D\n\nsignal getroffen(schaden: int)\n\nfunc _ready() -> void:\n\tgetroffen.connect(_on_getroffen)\n\nfunc _on_getroffen(schaden: int) -> void:\n\tprint("Schaden: %d" % schaden)\n```\n\n| Knoten | Zweck |\n|---|---|\n| Node2D | 2D-Transform |\n| Control | UI |\n\n'
const kap = (ord, titel, teil, status, n) => ({
id: String(ord).padStart(2, '0'), ord, titel, teil, datei: `buch/${String(ord).padStart(2, '0')}.md`, status,
intro: status === 'fehlt' ? '' : `Worum es in Kapitel ${ord} geht: ${titel}. Dieses Kapitel führt in die Grundlagen ein.`,
abschnitte: status === 'fehlt' ? [] : Array.from({ length: n }, (_, i) => ({
id: `${String(ord).padStart(2, '0')}-${i + 1}`, nr: `${ord}.${i + 1}`, titel: `Abschnitt ${i + 1} von ${titel}`,
text: text(4) + (i === 0 ? code : ''), woerter: 1400, minuten: 8 })),
woerter: status === 'fehlt' ? 0 : n * 1400, minuten: status === 'fehlt' ? 0 : n * 8,
berichte: status === 'fehlt' ? { autor: '', pruefer: '' } : { autor: bericht, pruefer: status === 'geschrieben' ? '' : '## Prüfung\n\n- 3.1: belegt\n- 3.2: eine Zahl korrigiert' },
})
const guide = {
titel: 'Godot 4', untertitel: 'Architektur, Konzepte und Praxis der Engine', status: 'laeuft',
vorwort: 'Dieses Buch erklärt Godot 4 entlang seiner Konzepte.\n\nEs richtet sich an Entwickler, die die Engine **verstehen** wollen, nicht nur bedienen.',
kapitel: [
kap(1, 'Die Engine im Überblick', 'Teil I – Grundlagen', 'konsistent', 3),
kap(2, 'Szenen und Knoten', 'Teil I – Grundlagen', 'fehlt', 0),
kap(3, 'GDScript', 'Teil I – Grundlagen', 'geprueft', 4),
kap(4, 'Signale', 'Teil II – Praxis', 'geschrieben', 2),
],
woerter: 12600, minuten: 72,
gliederung: '# Gliederung\n\n## Teil I – Grundlagen\n\n1. Die Engine im Überblick\n2. Szenen und Knoten\n3. GDScript\n\n## Teil II – Praxis\n\n4. Signale',
quellen: '# Quellen\n\n- Godot-Dokumentation 4.6 (docs.godotengine.org)\n- Engine-Quelltext, Tag 4.6-stable\n\n## Prüfmethode\n\nJedes Kapitel wird von einem unabhängigen Agenten gegen die Quellen geprüft.',
pdf: false,
}
function json(res, code, body) {
res.writeHead(code, { 'content-type': 'application/json' })
res.end(JSON.stringify(body))
}
http.createServer((req, res) => {
const url = new URL(req.url, 'http://x')
const p = url.pathname
let m
if (req.method === 'GET' && p === '/api/topics') return json(res, 200, topics)
if (req.method === 'POST' && p === '/api/topics') {
let body = ''
req.on('data', (c) => body += c)
return req.on('end', () => {
const { name } = JSON.parse(body || '{}')
const t = { name, titel: name, status: 'neu', laeuft: false, stufe: '' }
topics.push(t); json(res, 200, t)
})
}
if ((m = p.match(/^\/api\/topics\/([^/]+)$/)) && req.method === 'DELETE') {
const i = topics.findIndex((t) => t.name === decodeURIComponent(m[1]))
if (i >= 0) topics.splice(i, 1)
return json(res, 200, { ok: true })
}
if ((m = p.match(/^\/api\/topics\/([^/]+)\/(start|pause|reset)$/)) && req.method === 'POST') {
const t = topics.find((t) => t.name === decodeURIComponent(m[1]))
if (!t) return json(res, 404, { detail: 'unbekanntes Thema' })
t.status = m[2] === 'start' ? 'laeuft' : m[2] === 'pause' ? 'pausiert' : 'neu'; t.laeuft = t.status === 'laeuft'
if (t.name === state.name) state.status = t.status
return json(res, 200, { ok: true })
}
if ((m = p.match(/^\/api\/topics\/([^/]+)\/state$/))) {
const name = decodeURIComponent(m[1])
const t = topics.find((t) => t.name === name)
if (!t) return json(res, 404, { detail: 'unbekanntes Thema' })
if (name === state.name) return json(res, 200, state)
return json(res, 200, { name, titel: t.titel, hinweise: '', status: t.status, grund: '', stufe: '', deutung: null,
stufen: state.stufen.map((s) => ({ ...s, status: 'offen', fertig: 0, kosten_usd: 0, tokens: 0, gestartet: '', beendet: '' })),
kosten_usd: 0, tokens: 0, agenten: [], ereignisse: [] })
}
if ((m = p.match(/^\/api\/topics\/([^/]+)\/guide$/))) {
const name = decodeURIComponent(m[1])
if (name === state.name) return json(res, 200, guide)
return json(res, 200, { titel: name, untertitel: '', status: 'neu', vorwort: '', kapitel: [], woerter: 0, minuten: 0, gliederung: '', quellen: '', pdf: false })
}
if ((m = p.match(/^\/api\/topics\/([^/]+)\/guide\.md$/))) {
res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8' }); return res.end('# ' + guide.titel + '\n')
}
if ((m = p.match(/^\/api\/topics\/([^/]+)\/buch\.pdf$/))) return json(res, 404, { detail: 'PDF noch nicht gebaut' })
json(res, 404, { detail: 'nicht gefunden: ' + p })
}).listen(PORT, () => console.log(`Mock-Backend auf http://localhost:${PORT}`))

1461
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
frontend/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "buchwerk-frontend",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5190",
"build": "vite build"
},
"dependencies": {
"dompurify": "^3.2.0",
"highlight.js": "^11.11.0",
"markdown-it": "^14.1.0",
"vue": "^3.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
"vite": "^7.0.0"
}
}

153
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,153 @@
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { api } from './api.js'
import Lauf from './components/Lauf.vue'
import Guide from './components/Guide.vue'
const params = new URLSearchParams(location.search)
const topics = ref([])
const topic = ref(params.get('topic') || localStorage.getItem('topic') || '')
const tab = ref((params.get('tab') || localStorage.getItem('tab')) === 'buch' ? 'buch' : 'lauf')
const theme = ref(localStorage.getItem('theme') || 'papier')
const state = ref(null)
const fehler = ref('')
const neuName = ref('')
const neuHinweise = ref('')
const navAuf = ref(false)
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)
themeAnwenden()
// URL spiegelt Thema und Tab; beim Themenwechsel fällt der Deep-Link (#a…/#k…) weg
function urlSchreiben(hashBehalten) {
const p = new URLSearchParams()
if (topic.value) p.set('topic', topic.value)
p.set('tab', tab.value)
history.replaceState(null, '', location.pathname + '?' + p + (hashBehalten ? location.hash : ''))
}
watch(topic, (t) => { localStorage.setItem('topic', t); urlSchreiben(false) })
watch(tab, (t) => { localStorage.setItem('tab', t); urlSchreiben(true) })
const aktuell = computed(() => topics.value.find((t) => t.name === topic.value))
const laeuft = computed(() => state.value?.status === 'laeuft')
const fortschritt = computed(() => (state.value?.stufen || []).reduce((n, s) => n + (s.fertig || 0), 0))
async function topicsLaden() {
try {
topics.value = await api.topics()
fehler.value = ''
if (topic.value && !topics.value.some((t) => t.name === topic.value)) topic.value = ''
if (!topic.value && topics.value.length) topic.value = topics.value[0].name
} catch (e) {
// Server gerade nicht erreichbar (z. B. Neustart): alle 3 s erneut versuchen, bis er antwortet
fehler.value = 'Server nicht erreichbar — versuche es weiter …'
setTimeout(async () => { await topicsLaden(); if (!fehler.value) { await stateLaden(); if (laeuft.value) pollen() } }, 3000)
}
}
async function stateLaden() {
if (!topic.value) { state.value = null; return }
try { state.value = await api.state(topic.value) } catch (e) { fehler.value = String(e.message || e) }
}
function pollen() {
clearInterval(timer)
timer = setInterval(async () => {
if (!topic.value) return
await stateLaden()
if (!laeuft.value) { clearInterval(timer); timer = null; await topicsLaden() }
}, 2000)
}
watch(topic, async () => { await stateLaden(); if (laeuft.value) pollen() })
watch(laeuft, (l) => { if (l && !timer) pollen() })
async function anlegen() {
const name = neuName.value.trim()
if (!name) return
try {
await api.anlegen(name, neuHinweise.value.trim())
neuName.value = ''
neuHinweise.value = ''
await topicsLaden()
topic.value = name
tab.value = 'lauf'
} catch (e) { fehler.value = String(e.message || e) }
}
async function aktion(fn) {
try { await fn(); await stateLaden(); await topicsLaden(); if (laeuft.value) pollen() }
catch (e) { fehler.value = String(e.message || e) }
}
async function loeschen(name) {
if (!confirm(`Thema „${name}“ samt Buch löschen?`)) return
await aktion(() => api.loeschen(name))
}
function esc(e) { if (e.key === 'Escape') navAuf.value = false }
onMounted(async () => { window.addEventListener('keydown', esc); urlSchreiben(true); await topicsLaden(); await stateLaden(); if (laeuft.value) pollen() })
onUnmounted(() => { clearInterval(timer); window.removeEventListener('keydown', esc) })
const statusBadge = computed(() => {
const s = state.value?.status
return { fertig: ['gruen', 'fertig'], laeuft: ['blau', 'läuft'], pausiert: ['gelb', 'pausiert'],
fehler: ['rot', 'Fehler'], neu: ['', 'neu'] }[s] || ['', s || '']
})
function tokens(n) { return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? Math.round(n / 1e3) + 'k' : String(n || 0) }
</script>
<template>
<div class="app" :class="{ 'tab-buch': tab === 'buch' }">
<div v-if="navAuf" class="backdrop" @click="navAuf = false"></div>
<aside class="sidebar" :class="{ offen: navAuf }">
<button class="schliessen sekundaer klein" @click="navAuf = false">✕ Schließen</button>
<div class="topics">
<div v-for="t in topics" :key="t.name" class="topic-eintrag" :class="{ aktiv: t.name === topic }"
@click="topic = t.name; navAuf = false">
<span class="punkt" :class="t.laeuft ? 'laeuft' : t.status"></span>
<span style="flex: 1">{{ t.titel }}</span>
<button class="klein sekundaer" title="Löschen" @click.stop="loeschen(t.name)">✕</button>
</div>
<div v-if="!topics.length" style="color: var(--dim); padding: 8px">Noch kein Thema.</div>
</div>
<div class="neu">
<input v-model="neuName" placeholder="Thema, z. B. Godot 4" @keyup.enter="anlegen" />
<input v-model="neuHinweise" placeholder="Hinweise (optional), z. B. für Entwickler, ab Version 4.6" @keyup.enter="anlegen" />
<button @click="anlegen">Anlegen</button>
</div>
</aside>
<div class="haupt">
<div class="topbar">
<button class="burger sekundaer klein" title="Themen" @click="navAuf = !navAuf">☰</button>
<span class="titel">{{ aktuell?.titel || '' }}</span>
<div class="tabs">
<button :class="{ aktiv: tab === 'lauf' }" @click="tab = 'lauf'">Lauf</button>
<button :class="{ aktiv: tab === 'buch' }" @click="tab = 'buch'">Buch</button>
</div>
<span v-if="state" class="badge" :class="statusBadge[0]">{{ statusBadge[1] }}</span>
<span v-if="tab === 'lauf' && state?.tokens" class="badge">{{ (state.kosten_usd || 0).toFixed(2) }} $ · {{ tokens(state.tokens) }} Tokens</span>
<span class="spacer"></span>
<select v-model="theme" title="Farbschema">
<option value="papier">papier</option>
<option value="dunkel">dunkel</option>
<option value="hell">hell</option>
<option value="auto">auto</option>
</select>
</div>
<div v-if="fehler" class="fehler" @click="fehler = ''">{{ fehler }} — klicken zum Schließen</div>
<div class="inhalt">
<Lauf v-if="tab === 'lauf' && state" :state="state"
@start="aktion(() => api.start(topic))"
@pause="aktion(() => api.pause(topic))"
@reset="aktion(() => api.reset(topic))" />
<Guide v-else-if="tab === 'buch' && topic" :topic="topic" :laeuft="laeuft" :fortschritt="fortschritt" />
<div v-else style="padding: 24px; color: var(--dim)">Thema links anlegen oder wählen.</div>
</div>
</div>
</div>
</template>

28
frontend/src/api.js Normal file
View File

@@ -0,0 +1,28 @@
async function req(pfad, opts = {}) {
const r = await fetch('/api' + pfad, {
headers: { 'content-type': 'application/json' },
...opts,
body: opts.body ? JSON.stringify(opts.body) : undefined,
})
if (!r.ok) {
let detail = r.statusText
try { detail = (await r.json()).detail || detail } catch { /* leer */ }
throw new Error(detail)
}
const ct = r.headers.get('content-type') || ''
return ct.includes('json') ? r.json() : r.text()
}
const e = encodeURIComponent
export const api = {
topics: () => req('/topics'),
anlegen: (name, hinweise = '') => req('/topics', { method: 'POST', body: { name, hinweise } }),
loeschen: (t) => req(`/topics/${e(t)}`, { method: 'DELETE' }),
start: (t) => req(`/topics/${e(t)}/start`, { method: 'POST' }),
pause: (t) => req(`/topics/${e(t)}/pause`, { method: 'POST' }),
reset: (t) => req(`/topics/${e(t)}/reset`, { method: 'POST' }),
state: (t) => req(`/topics/${e(t)}/state`),
guide: (t) => req(`/topics/${e(t)}/guide`),
guideMdUrl: (t) => `/api/topics/${e(t)}/guide.md`,
pdfUrl: (t) => `/api/topics/${e(t)}/buch.pdf`,
}

View File

@@ -0,0 +1,174 @@
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { api } from '../api.js'
import { render, zeit } from '../markdown.js'
import Lesemodus from './Lesemodus.vue'
const props = defineProps({ topic: String, laeuft: Boolean, fortschritt: Number })
const daten = ref(null)
const lesemodus = ref(false)
const navAuf = ref(false)
// Wischgeste (nur Buchansicht, nicht im Lesemodus): nach rechts öffnet das Inhaltsverzeichnis, nach links schließt es
let wisch = null
function wischStart(e) {
const t = e.touches[0]
wisch = { x: t.clientX, y: t.clientY, t: Date.now() }
}
function wischEnde(e) {
if (!wisch || lesemodus.value || window.innerWidth > 800) return
const t = e.changedTouches[0]
const dx = t.clientX - wisch.x, dy = t.clientY - wisch.y, dauer = Date.now() - wisch.t
wisch = null
if (dauer > 800 || Math.abs(dx) < 60 || Math.abs(dy) > Math.abs(dx) * 0.6) return
navAuf.value = dx > 0
}
const aktiv = ref('')
const lese = ref(null)
let beobachter = null
let ladeSeq = 0
const cache = new Map() // Schlüssel: Abschnitt + Textlänge → unveränderte Abschnitte werden nicht neu gerendert
onUnmounted(() => { beobachter?.disconnect() })
async function laden() {
if (!props.topic) { daten.value = null; return }
const seq = ++ladeSeq
const d = await api.guide(props.topic)
if (seq !== ladeSeq) return
daten.value = d
await nextTick()
spy()
// Deep-Link (#a03-1 / #k03) hat Vorrang vor der gemerkten Position; beides nur beim ersten Laden
const ziel = location.hash.slice(1) || localStorage.getItem('pos:' + props.topic)
if (ziel && !aktiv.value) document.getElementById(ziel)?.scrollIntoView({ block: 'start' })
}
watch(() => props.topic, () => { aktiv.value = ''; cache.clear(); laden() }, { immediate: true })
watch(() => props.laeuft, (l, alt) => { if (alt && !l) laden() })
// Während eines Laufs nur nachladen, wenn sich der Fortschritt geändert hat
watch(() => props.fortschritt, (n, alt) => { if (props.laeuft && n !== alt) laden() })
function html(a) {
const key = a.id + ':' + (a.text || '').length
if (!cache.has(key)) cache.set(key, render(a.text))
return cache.get(key)
}
const abschnitte = computed(() => (daten.value?.kapitel || []).flatMap((k) => k.abschnitte))
const gelesen = computed(() => {
const ids = abschnitte.value.map((a) => 'a' + a.id)
const i = ids.indexOf(aktiv.value)
return ids.length ? Math.round(100 * Math.max(0, i) / ids.length) : 0
})
// Kapitel, in dem die aktive Stelle liegt — nur dieses zeigt in der Nav seine Abschnitte; sonst das erste
const aktivKap = computed(() => {
const id = aktiv.value, ks = daten.value?.kapitel || []
const k = ks.find((k) => 'k' + k.id === id || k.abschnitte.some((a) => 'a' + a.id === id))
return (k || ks[0])?.id || ''
})
function teilNeu(i) {
const ks = daten.value.kapitel
return ks[i].teil && (i === 0 || ks[i - 1].teil !== ks[i].teil)
}
const kapBadge = { geschrieben: ['gelb', 'geschrieben'], geprueft: ['blau', 'geprüft'], fehlt: ['', 'fehlt'] }
function spy() {
beobachter?.disconnect()
beobachter = new IntersectionObserver((es) => {
for (const e of es) if (e.isIntersecting) { aktiv.value = e.target.id; localStorage.setItem('pos:' + props.topic, e.target.id) }
}, { root: lese.value, rootMargin: '0px 0px -75% 0px' })
document.querySelectorAll('.buch [data-spy]').forEach((el) => beobachter.observe(el))
}
function springe(id) {
navAuf.value = false
aktiv.value = id
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
history.replaceState(null, '', location.pathname + location.search + '#' + id)
}
function taste(e) { if (e.key === 'Escape') navAuf.value = false }
onMounted(() => window.addEventListener('keydown', taste))
onUnmounted(() => window.removeEventListener('keydown', taste))
</script>
<template>
<div class="guide">
<div v-if="navAuf" class="backdrop" @click="navAuf = false"></div>
<nav class="guide-nav" :class="{ offen: navAuf }">
<button class="schliessen sekundaer klein" @click="navAuf = false">✕ Schließen</button>
<div class="werkzeuge">
<button class="klein" title="Lesemodus: Abschnitt für Abschnitt" @click="lesemodus = true" :disabled="!abschnitte.length">Lesen ▶</button>
<a class="knopf klein sekundaer" title="Als Markdown-Datei herunterladen" :href="api.guideMdUrl(topic)" download>MD</a>
<a v-if="daten?.pdf" class="knopf klein sekundaer" title="Als PDF herunterladen" :href="api.pdfUrl(topic)" download>PDF</a>
</div>
<template v-if="daten">
<div class="meta">
{{ daten.kapitel.length }} Kapitel · {{ daten.woerter.toLocaleString('de-DE') }} Wörter · {{ zeit(daten.minuten) }}
<div class="fortschritt"><i :style="{ width: gelesen + '%' }"></i></div>
</div>
<template v-for="(k, i) in daten.kapitel" :key="k.id">
<div v-if="teilNeu(i)" class="teil">{{ k.teil }}</div>
<a class="kap" :class="{ aktiv: aktiv === 'k' + k.id }" @click="springe('k' + k.id)">
<span class="pfeil">{{ aktivKap === k.id ? '▾' : '▸' }}</span>
<span class="t">{{ k.ord }}. {{ k.titel }}</span><span class="zeit">{{ zeit(k.minuten) }}</span></a>
<template v-if="aktivKap === k.id">
<a v-for="a in k.abschnitte" :key="a.id" class="abs" :class="{ aktiv: aktiv === 'a' + a.id }" @click="springe('a' + a.id)">
<span>{{ a.nr }} {{ a.titel }}</span><span class="zeit">{{ zeit(a.minuten) }}</span></a>
</template>
</template>
<div v-if="!daten.kapitel.length" class="leer">Noch kein Text — das Buch erscheint hier, sobald die ersten Kapitel geschrieben sind.</div>
</template>
</nav>
<div ref="lese" class="lese" @touchstart.passive="wischStart" @touchend.passive="wischEnde">
<div class="mobil-leiste">
<button class="sekundaer klein" @click="navAuf = !navAuf">☰ Inhalt</button>
<button class="klein" @click="lesemodus = true" :disabled="!abschnitte.length">Lesen ▶</button>
<a class="knopf klein sekundaer" :href="api.guideMdUrl(topic)" download>MD</a>
<a v-if="daten?.pdf" class="knopf klein sekundaer" :href="api.pdfUrl(topic)" download>PDF</a>
</div>
<article v-if="daten" class="buch">
<h1>{{ daten.titel }}</h1>
<div v-if="daten.untertitel" class="untertitel">{{ daten.untertitel }}</div>
<div class="meta">{{ daten.kapitel.length }} Kapitel · {{ daten.woerter.toLocaleString('de-DE') }} Wörter · {{ zeit(daten.minuten) }} Lesezeit</div>
<p v-if="daten.status && daten.status !== 'fertig'" class="hinweis">
Stand: {{ daten.status }} — Kapitel erscheinen, sobald sie geschrieben sind, und werden danach von einem unabhängigen Agenten geprüft.</p>
<section v-if="daten.vorwort" id="vorwort" class="vorwort" data-spy>
<h2>Vorwort</h2>
<div v-html="render(daten.vorwort)"></div>
</section>
<section v-for="(k, i) in daten.kapitel" :key="k.id" :id="'k' + k.id" class="kapitel" data-spy>
<div v-if="teilNeu(i)" class="teil-titel">{{ k.teil }}</div>
<h2>Kapitel {{ k.ord }} – {{ k.titel }}<span v-if="k.status !== 'konsistent' && kapBadge[k.status]" class="badge" :class="kapBadge[k.status][0]">{{ kapBadge[k.status][1] }}</span></h2>
<p v-if="k.status === 'fehlt'" class="hinweis">Dieses Kapitel wird noch geschrieben.</p>
<div v-else class="kap-meta">{{ k.abschnitte.length }} Abschnitte · {{ zeit(k.minuten) }}</div>
<div v-if="k.intro" class="kap-intro" v-html="render(k.intro)"></div>
<section v-for="a in k.abschnitte" :key="a.id" :id="'a' + a.id" data-spy>
<h3>{{ a.nr }} {{ a.titel }}</h3>
<div v-html="html(a)"></div>
</section>
<details v-if="k.berichte?.autor || k.berichte?.pruefer" class="pruefung">
<summary>Prüfung</summary>
<template v-if="k.berichte.autor">
<h4>Autorenbericht</h4>
<div class="md" v-html="render(k.berichte.autor)"></div>
</template>
<template v-if="k.berichte.pruefer">
<h4>Prüfbericht</h4>
<div class="md" v-html="render(k.berichte.pruefer)"></div>
</template>
</details>
</section>
<div v-if="daten.quellen || daten.gliederung" class="ende">
<details v-if="daten.quellen">
<summary>Quellen und Prüfmethode</summary>
<div class="md" v-html="render(daten.quellen)"></div>
</details>
<details v-if="daten.gliederung">
<summary>Gliederung</summary>
<div class="md" v-html="render(daten.gliederung)"></div>
</details>
</div>
</article>
</div>
<Lesemodus v-if="lesemodus && daten" :daten="daten" :topic="topic" @schliessen="lesemodus = false" />
</div>
</template>

View File

@@ -0,0 +1,105 @@
<script setup>
import { computed } from 'vue'
import { render } from '../markdown.js'
const props = defineProps({ state: Object })
const emit = defineEmits(['start', 'pause', 'reset'])
const laeuft = computed(() => props.state?.status === 'laeuft')
const fertig = computed(() => props.state?.status === 'fertig')
const begonnen = computed(() => ['pausiert', 'fehler', 'fertig'].includes(props.state?.status)
|| (props.state?.stufen || []).some((s) => s.status !== 'offen'))
function usd(n) { return (n || 0).toFixed(2) + ' $' }
function tokens(n) { return n >= 1e6 ? (n / 1e6).toFixed(2) + ' M' : n >= 1e3 ? Math.round(n / 1e3) + ' k' : String(n || 0) }
function dauer(s) {
if (!s.gestartet) return ''
const a = new Date(s.gestartet)
const b = s.beendet ? new Date(s.beendet) : new Date()
const sek = Math.max(0, Math.round((b - a) / 1000))
return sek < 90 ? `${sek} s` : `${Math.round(sek / 60)} min`
}
function uhr(t) { return t ? new Date(t).toLocaleTimeString('de-DE') : '' }
const statusText = { offen: 'wartet', laeuft: 'läuft', fertig: 'fertig', pausiert: 'pausiert', fehler: 'Fehler', abgebrochen: 'abgebrochen' }
const statusFarbe = { laeuft: 'blau', fertig: 'gruen', fehler: 'rot', abgebrochen: 'gelb', pausiert: 'gelb' }
// Laufende Agenten zuerst, danach die übrigen nach Stufe (in Stufenreihenfolge)
const gruppen = computed(() => {
const ag = props.state?.agenten || []
const out = [{ titel: 'Läuft gerade', agenten: ag.filter((a) => a.status === 'laeuft') }]
const rest = ag.filter((a) => a.status !== 'laeuft')
const stufen = (props.state?.stufen || []).map((s) => s.id)
for (const id of stufen) out.push({ titel: props.state.stufen.find((s) => s.id === id).titel, agenten: rest.filter((a) => a.stufe === id) })
out.push({ titel: 'Sonstige', agenten: rest.filter((a) => !stufen.includes(a.stufe)) })
return out.filter((g) => g.agenten.length)
})
const ereignisse = computed(() => (props.state?.ereignisse || []).slice(-100).reverse())
function reset() { if (confirm('Lauf zurücksetzen? Alles außer dem Thema wird gelöscht.')) emit('reset') }
</script>
<template>
<div class="lauf">
<div class="aktionen">
<button v-if="!laeuft && !fertig" @click="emit('start')">{{ begonnen ? 'Fortsetzen' : 'Buch erzeugen' }}</button>
<button v-if="laeuft" class="sekundaer" @click="emit('pause')">Pause</button>
<button v-if="!laeuft && begonnen" class="sekundaer" @click="reset">Zurücksetzen</button>
<span class="spacer"></span>
<span class="badge" :class="statusFarbe[state.status]">{{ statusText[state.status] || state.status }}<template v-if="state.stufe && laeuft"> · {{ state.stufe }}</template></span>
<span class="badge">{{ usd(state.kosten_usd) }}</span>
<span class="badge">{{ tokens(state.tokens) }} Tokens</span>
</div>
<div v-if="state.grund" class="grund">{{ state.grund }}</div>
<div v-if="state.deutung" class="deutung">
<div class="titel">{{ state.deutung.titel }}</div>
<div v-if="state.deutung.untertitel" class="sub">{{ state.deutung.untertitel }}</div>
<p v-if="state.deutung.deutung">{{ state.deutung.deutung }}</p>
<div class="fakten">
<span v-if="state.deutung.buchtyp"><b>Buchtyp</b> {{ state.deutung.buchtyp === 'werkzeug' ? 'Werkzeug' : state.deutung.buchtyp === 'wissen' ? 'Wissen' : state.deutung.buchtyp }}</span>
<span v-if="state.deutung.zielgruppe"><b>Zielgruppe</b> {{ state.deutung.zielgruppe }}</span>
<span v-if="state.deutung.referenz"><b>Referenz</b> {{ state.deutung.referenz }}</span>
</div>
</div>
<div class="stufen">
<div v-for="s in state.stufen" :key="s.id" class="stufe" :class="s.status">
<div class="kreis">{{ s.status === 'fertig' ? '✓' : '' }}</div>
<div>
<div class="name">{{ s.titel }}</div>
<div class="sub">{{ statusText[s.status] || s.status }}<template v-if="s.gesamt"> · {{ s.fertig }} / {{ s.gesamt }}</template></div>
<div v-if="s.gesamt" class="balken"><i :style="{ width: Math.min(100, 100 * s.fertig / s.gesamt) + '%' }"></i></div>
</div>
<div class="sub rechts">{{ s.tokens ? usd(s.kosten_usd) + ' · ' + tokens(s.tokens) + ' Tokens' : '' }}</div>
<div class="sub rechts">{{ dauer(s) }}</div>
</div>
</div>
<div v-if="gruppen.length" class="agenten">
<template v-for="g in gruppen" :key="g.titel">
<h3>{{ g.titel }}</h3>
<details v-for="a in g.agenten" :key="a.id" class="agent" :class="a.status">
<summary>
<span class="name">{{ a.name }}</span>
<span class="badge" :class="statusFarbe[a.status]">{{ statusText[a.status] || a.status }}</span>
<span class="sub">{{ usd(a.kosten_usd) }}</span>
<span class="sub">{{ tokens(a.tokens) }} Tokens</span>
<span class="sub">{{ a.werkzeuge || 0 }} Werkzeuge</span>
<span class="sub">{{ dauer(a) }}</span>
</summary>
<div v-if="a.bericht" class="bericht md" v-html="render(a.bericht)"></div>
<div v-else class="bericht sub">Noch kein Abschlussbericht.</div>
</details>
</template>
</div>
<details v-if="ereignisse.length" class="ereignisse">
<summary>Ereignisse ({{ ereignisse.length }})</summary>
<div v-for="(e, i) in ereignisse" :key="i" class="ereignis" :class="e.art">
<span class="zeit">{{ uhr(e.zeit) }}</span>
<span class="wer">{{ e.agent }}</span>
<span class="text">{{ e.text }}</span>
</div>
</details>
</div>
</template>

View File

@@ -0,0 +1,107 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { render, zeit } from '../markdown.js'
const props = defineProps({ daten: Object, topic: String })
const emit = defineEmits(['schliessen'])
const buehne = ref(null)
const pos = ref(0)
// Bildschirme: Vorwort, je Kapitel ein Intro, je Abschnitt ein Bildschirm, Ende.
const screens = computed(() => {
const out = []
const kaps = props.daten?.kapitel || []
if (props.daten?.vorwort) out.push({ typ: 'vorwort' })
kaps.forEach((kap, ki) => {
out.push({ typ: 'kapitel', kap, nr: ki + 1, von: kaps.length })
for (const a of kap.abschnitte) out.push({ typ: 'abschnitt', a, kap })
})
out.push({ typ: 'ende' })
return out
})
const screen = computed(() => screens.value[pos.value] || { typ: 'ende' })
const gesamt = computed(() => screens.value.filter((s) => s.typ === 'abschnitt').length)
const gelesen = computed(() => screens.value.slice(0, pos.value + 1).filter((s) => s.typ === 'abschnitt').length)
const cache = new Map()
function html(a) {
const key = a.id + ':' + (a.text || '').length
if (!cache.has(key)) cache.set(key, render(a.text))
return cache.get(key)
}
function vor() { if (pos.value < screens.value.length - 1) pos.value++ }
function zurueck() { if (pos.value > 0) pos.value-- }
watch(pos, (p) => { localStorage.setItem('lm:' + props.topic, String(p)); buehne.value?.scrollTo({ top: 0 }) })
function taste(e) {
if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); vor() }
else if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); zurueck() }
else if (e.key === 'Escape') emit('schliessen')
else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
buehne.value?.scrollBy({ top: e.key === 'ArrowDown' ? 160 : -160, behavior: 'smooth' })
}
}
let wisch = null
function wischStart(e) {
for (let el = e.target; el && el !== e.currentTarget; el = el.parentElement) {
if (el.scrollWidth > el.clientWidth + 1) { wisch = null; return }
}
const t = e.touches[0]; wisch = { x: t.clientX, y: t.clientY }
}
function wischEnde(e) {
if (!wisch) return
const t = e.changedTouches[0]
const dx = t.clientX - wisch.x, dy = t.clientY - wisch.y
wisch = null
if (Math.abs(dx) < 60 || Math.abs(dx) < 2 * Math.abs(dy)) return
dx < 0 ? vor() : zurueck()
}
onMounted(() => {
window.addEventListener('keydown', taste)
const merk = Number(localStorage.getItem('lm:' + props.topic) || 0)
pos.value = Math.min(merk, screens.value.length - 1)
})
onUnmounted(() => window.removeEventListener('keydown', taste))
</script>
<template>
<div class="lesemodus" @touchstart.passive="wischStart" @touchend.passive="wischEnde">
<header class="lm-kopf">
<span>{{ gelesen }} / {{ gesamt }} Abschnitte</span>
<div class="balken"><i :style="{ width: (gesamt ? 100 * gelesen / gesamt : 0) + '%' }"></i></div>
<span v-if="screen.typ === 'abschnitt'">{{ screen.kap.titel }}</span>
<button title="Schließen (Esc)" @click="emit('schliessen')">✕</button>
</header>
<main ref="buehne" class="lm-buehne">
<section v-if="screen.typ === 'vorwort'" class="lm-screen">
<div class="lm-label">Vorwort</div>
<h1>{{ daten.titel }}</h1>
<div v-if="daten.untertitel" class="stats" style="margin: 0 0 18px">{{ daten.untertitel }}</div>
<div class="intro" v-html="render(daten.vorwort)"></div>
<div class="stats">{{ daten.kapitel.length }} Kapitel · {{ daten.woerter.toLocaleString('de-DE') }} Wörter · {{ zeit(daten.minuten) }}</div>
</section>
<section v-else-if="screen.typ === 'kapitel'" class="lm-screen">
<div class="lm-label">Kapitel {{ screen.nr }} / {{ screen.von }}</div>
<h1>{{ screen.kap.titel }}</h1>
<div v-if="screen.kap.intro" class="intro" v-html="render(screen.kap.intro)"></div>
<div v-if="screen.kap.status === 'fehlt'" class="stats">Dieses Kapitel wird noch geschrieben.</div>
<div v-else class="stats">{{ screen.kap.abschnitte.length }} Abschnitte · {{ zeit(screen.kap.minuten) }}</div>
</section>
<section v-else-if="screen.typ === 'abschnitt'" class="lm-screen">
<div class="lm-label">{{ screen.kap.titel }}</div>
<h2>{{ screen.a.nr }} {{ screen.a.titel }}</h2>
<div v-html="html(screen.a)"></div>
</section>
<section v-else class="lm-screen ende">
<h1>Fertig gelesen</h1>
<p class="intro">Alle {{ gesamt }} Abschnitte von „{{ daten.titel }}“.</p>
<button class="sekundaer" style="margin-top: 16px" @click="pos = 0">Von vorn</button>
</section>
</main>
<footer class="lm-fuss">
<button :disabled="pos === 0" @click="zurueck">← Zurück</button>
<button class="weiter" :disabled="pos >= screens.length - 1" @click="vor">Weiter →<kbd>␣</kbd></button>
</footer>
</div>
</template>

5
frontend/src/main.js Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')

48
frontend/src/markdown.js Normal file
View File

@@ -0,0 +1,48 @@
import MarkdownIt from 'markdown-it'
import DOMPurify from 'dompurify'
import hljs from 'highlight.js/lib/core'
import python from 'highlight.js/lib/languages/python'
import php from 'highlight.js/lib/languages/php'
import javascript from 'highlight.js/lib/languages/javascript'
import typescript from 'highlight.js/lib/languages/typescript'
import json from 'highlight.js/lib/languages/json'
import yaml from 'highlight.js/lib/languages/yaml'
import bash from 'highlight.js/lib/languages/bash'
import xml from 'highlight.js/lib/languages/xml'
import css from 'highlight.js/lib/languages/css'
import csharp from 'highlight.js/lib/languages/csharp'
import cpp from 'highlight.js/lib/languages/cpp'
import c from 'highlight.js/lib/languages/c'
import glsl from 'highlight.js/lib/languages/glsl'
import ini from 'highlight.js/lib/languages/ini'
import sql from 'highlight.js/lib/languages/sql'
import rust from 'highlight.js/lib/languages/rust'
import go from 'highlight.js/lib/languages/go'
import java from 'highlight.js/lib/languages/java'
import twig from 'highlight.js/lib/languages/twig'
import diff from 'highlight.js/lib/languages/diff'
for (const [n, l] of Object.entries({ python, php, javascript, typescript, json, yaml, bash, xml, css, csharp, cpp, c, glsl, ini, sql, rust, go, java, twig, diff })) hljs.registerLanguage(n, l)
hljs.registerAliases(['js'], { languageName: 'javascript' }); hljs.registerAliases(['ts'], { languageName: 'typescript' })
hljs.registerAliases(['sh', 'shell', 'console', 'zsh'], { languageName: 'bash' }); hljs.registerAliases(['html', 'svg', 'tscn', 'tres'], { languageName: 'xml' })
hljs.registerAliases(['yml'], { languageName: 'yaml' }); hljs.registerAliases(['toml', 'cfg', 'godot'], { languageName: 'ini' }); hljs.registerAliases(['cs'], { languageName: 'csharp' })
hljs.registerAliases(['gdscript', 'gd'], { languageName: 'python' }) // nah genug für lesbare Farben
const md = new MarkdownIt({
html: true,
linkify: false,
typographer: false,
highlight(code, lang) {
const l = lang && hljs.getLanguage(lang) ? lang : null
const out = l ? hljs.highlight(code, { language: l }).value : md.utils.escapeHtml(code)
return `<pre class="hljs"><code>${out}</code></pre>`
},
})
export function render(text) {
return DOMPurify.sanitize(md.render(text || ''))
}
export function zeit(min) {
const m = Math.max(1, Math.round(min))
return m < 60 ? `~${m} min` : `~${Math.floor(m / 60)} h ${m % 60} min`
}

227
frontend/src/style.css Normal file
View File

@@ -0,0 +1,227 @@
/* Themes über Custom Properties: dunkel (Standard), hell, papier. */
:root {
--bg: #12161c; --panel: #181e27; --karte: #202834; --rand: #2d3746;
--text: #dbe2ec; --dim: #8d99aa; --akzent: #4f9cf7; --gruen: #43c17f; --gelb: #e6c04a; --rot: #e36b6b;
--lese-bg: #14181f; --lese-text: #c9d1dc; --lese-dim: #98a3b3; --lese-rand: #2a3340;
--code-bg: #0e1218;
--code-fg: #d8dee9; --c-kw: #c792ea; --c-str: #a5d6a7; --c-com: #7b8794; --c-num: #f6c177; --c-fn: #82aaff; --c-type: #ffcb6b;
}
:root[data-theme="light"] {
--bg: #f4f6f9; --panel: #ffffff; --karte: #eef1f5; --rand: #d5dbe3;
--text: #1d2531; --dim: #5f6b7c; --akzent: #1b6fd6; --gruen: #1f8a57; --gelb: #9a7a12; --rot: #c43d3d;
--lese-bg: #ffffff; --lese-text: #2b3440; --lese-dim: #5c6674; --lese-rand: #dfe4ea;
--code-bg: #f1f3f6;
--code-fg: #1f2933; --c-kw: #7c3aed; --c-str: #1a7f37; --c-com: #6b7280; --c-num: #b45309; --c-fn: #1d4ed8; --c-type: #0e7490;
}
:root[data-theme="sepia"] {
--bg: #efe6d2; --panel: #f8f1de; --karte: #eee3c8; --rand: #d8caa6;
--text: #43392a; --dim: #82745a; --akzent: #7a5b2c; --gruen: #4f7a42; --gelb: #8f6b14; --rot: #a6442e;
--lese-bg: #f6efdd; --lese-text: #3f3527; --lese-dim: #6f6249; --lese-rand: #d9cba8;
--code-bg: #ece2c7;
--code-fg: #3b3224; --c-kw: #7a3e9d; --c-str: #2f6b2f; --c-com: #8a7c62; --c-num: #9a5b12; --c-fn: #2b5aa6; --c-type: #1c6f74;
}
* { box-sizing: border-box; }
html, body, #app { margin: 0; height: 100%; }
body { background: var(--bg); color: var(--text); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
button, a.knopf { background: var(--akzent); color: #fff; border: 0; border-radius: 6px; padding: 6px 14px; cursor: pointer; font: inherit; }
button.sekundaer, a.knopf.sekundaer { background: var(--karte); color: var(--text); border: 1px solid var(--rand); }
button.rot { background: var(--rot); }
button.klein, a.knopf.klein { padding: 3px 9px; font-size: 12px; }
button:disabled { opacity: 0.45; cursor: default; }
input, select { background: var(--karte); color: var(--text); border: 1px solid var(--rand); border-radius: 6px; padding: 6px 10px; font: inherit; }
a { color: var(--akzent); }
a.knopf { display: inline-block; text-decoration: none; line-height: normal; }
.app { height: 100%; display: flex; }
.sidebar { width: 230px; min-width: 230px; background: var(--panel); border-right: 1px solid var(--rand); display: flex; flex-direction: column; }
.sidebar .topics { flex: 1; overflow-y: auto; padding: 8px; }
.topic-eintrag { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 6px; cursor: pointer; color: var(--dim); }
.topic-eintrag.aktiv { background: var(--karte); color: var(--text); }
.topic-eintrag .punkt { width: 8px; height: 8px; border-radius: 50%; background: var(--dim); flex: none; }
.topic-eintrag .punkt.fertig { background: var(--gruen); }
.topic-eintrag .punkt.laeuft { background: var(--akzent); animation: puls 1.2s infinite; }
.topic-eintrag .punkt.pausiert { background: var(--gelb); }
.topic-eintrag .punkt.fehler { background: var(--rot); }
@keyframes puls { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.sidebar .neu { padding: 10px; border-top: 1px solid var(--rand); display: grid; gap: 6px; }
/* Eingeklappte Themenliste (Tab „Buch“ und schmale Bildschirme): ☰ öffnet sie als Overlay */
.schliessen { display: none; }
.sidebar.offen { display: flex; position: fixed; inset: 0 auto 0 0; z-index: 25; box-shadow: 0 0 30px rgba(0,0,0,0.4); }
.sidebar.offen .schliessen { display: inline-block; margin: 10px 10px 4px; align-self: flex-start; }
.burger { display: none; }
.app.tab-buch .burger { display: inline-block; }
.app.tab-buch .sidebar:not(.offen) { display: none; }
.backdrop { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.35); z-index: 24; }
.haupt { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.topbar { display: flex; gap: 10px; align-items: center; padding: 8px 16px; background: var(--panel); border-bottom: 1px solid var(--rand); flex-wrap: wrap; }
.topbar .titel { font-weight: 600; margin-right: 6px; }
.tabs { display: flex; gap: 4px; }
.tabs button { background: transparent; color: var(--dim); }
.tabs button.aktiv { background: var(--karte); color: var(--text); }
.spacer { flex: 1; }
.badge { display: inline-block; padding: 2px 9px; border-radius: 10px; font-size: 11.5px; background: var(--karte); color: var(--dim); }
.badge.gruen { color: var(--gruen); } .badge.gelb { color: var(--gelb); } .badge.rot { color: var(--rot); } .badge.blau { color: var(--akzent); }
.inhalt { flex: 1; overflow: hidden; display: flex; flex-direction: column; min-height: 0; }
.fehler { margin: 10px 16px; padding: 8px 12px; border: 1px solid var(--rot); border-radius: 6px; color: var(--rot); cursor: pointer; }
/* Lauf-Ansicht */
.lauf { padding: 18px 22px; overflow-y: auto; }
.lauf .aktionen { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; }
.lauf .grund { margin: 8px 0 14px; padding: 8px 12px; background: var(--karte); border-left: 3px solid var(--gelb); border-radius: 4px; }
.deutung { max-width: 760px; margin: 0 0 16px; padding: 12px 14px; background: var(--panel); border: 1px solid var(--rand); border-radius: 8px; }
.deutung .titel { font-weight: 600; font-size: 15px; }
.deutung .sub { color: var(--dim); font-size: 12.5px; }
.deutung p { margin: 8px 0; }
.deutung .fakten { display: flex; gap: 14px; flex-wrap: wrap; font-size: 12.5px; color: var(--dim); }
.deutung .fakten b { font-weight: 600; color: var(--text); margin-right: 4px; }
.stufen { display: grid; gap: 6px; max-width: 760px; }
.stufe { display: grid; grid-template-columns: 26px 1fr auto auto; gap: 10px; align-items: center; padding: 9px 12px; background: var(--panel); border: 1px solid var(--rand); border-radius: 8px; }
.stufe .kreis { width: 18px; height: 18px; border-radius: 50%; border: 2px solid var(--rand); display: grid; place-items: center; font-size: 11px; }
.stufe.fertig .kreis { background: var(--gruen); border-color: var(--gruen); color: #fff; }
.stufe.laeuft .kreis { border-color: var(--akzent); border-top-color: transparent; animation: dreh 0.9s linear infinite; }
.stufe.pausiert .kreis { border-color: var(--gelb); } .stufe.fehler .kreis { background: var(--rot); border-color: var(--rot); }
@keyframes dreh { to { transform: rotate(360deg); } }
.stufe .name { font-weight: 600; }
.stufe .sub { color: var(--dim); font-size: 12px; }
.stufe .balken { height: 4px; background: var(--karte); border-radius: 2px; margin-top: 5px; overflow: hidden; }
.stufe .balken i { display: block; height: 100%; background: var(--akzent); }
.stufe.fertig .balken i { background: var(--gruen); }
/* Agenten: Zeile aufklappen zeigt den Abschlussbericht */
.agenten { max-width: 760px; margin-top: 18px; }
.agenten h3, .ereignisse summary { margin: 18px 0 8px; font-size: 13px; font-weight: 600; color: var(--dim); text-transform: uppercase; letter-spacing: 0.05em; }
.agent { background: var(--panel); border: 1px solid var(--rand); border-radius: 8px; margin-bottom: 6px; }
.agent.laeuft { border-color: var(--akzent); }
.agent.fehler { border-color: var(--rot); }
.agent summary { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; padding: 8px 12px; cursor: pointer; list-style: none; }
.agent summary::-webkit-details-marker { display: none; }
.agent summary .name { font-weight: 600; flex: 1; min-width: 160px; }
.agent summary .sub { color: var(--dim); font-size: 12px; white-space: nowrap; }
.agent .bericht { padding: 6px 12px 12px; border-top: 1px solid var(--rand); }
.agent .bericht.sub { color: var(--dim); font-size: 12.5px; }
.ereignisse { max-width: 760px; margin-top: 18px; }
.ereignisse summary { cursor: pointer; }
.ereignis { display: grid; grid-template-columns: 64px 130px 1fr; gap: 10px; padding: 2px 0; font: 12px/1.5 "SF Mono", Consolas, Menlo, monospace; border-bottom: 1px solid var(--rand); }
.ereignis .zeit, .ereignis .wer { color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ereignis .text { overflow-wrap: anywhere; }
.ereignis.fehler .text { color: var(--rot); }
.ereignis.start .text, .ereignis.ende .text { color: var(--akzent); }
/* Gerendertes Markdown außerhalb des Buchtexts (Berichte, Prüfung, Quellen, Gliederung) */
.md { font: 13px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; }
.md h1, .md h2, .md h3, .md h4 { font-size: 13.5px; margin: 12px 0 4px; line-height: 1.4; }
.md p, .md ul, .md ol { margin: 0 0 0.7em; }
.md ul, .md ol { padding-left: 1.4em; }
.md li { margin: 0.15em 0; }
.md code { font-family: "SF Mono", Consolas, Menlo, monospace; font-size: 0.9em; background: var(--code-bg); padding: 1px 5px; border-radius: 4px; overflow-wrap: anywhere; }
.md pre { background: var(--code-bg); color: var(--code-fg); padding: 10px 12px; border-radius: 6px; overflow-x: auto; font-size: 0.9em; line-height: 1.5; }
.md pre code { background: none; padding: 0; }
.md table { border-collapse: collapse; margin: 0 0 0.8em; display: block; max-width: 100%; overflow-x: auto; }
.md th, .md td { border: 1px solid var(--rand); padding: 3px 8px; text-align: left; }
/* Buch-Ansicht */
.guide { flex: 1; display: flex; min-height: 0; }
.guide-nav { width: 270px; min-width: 240px; overflow-y: auto; padding: 14px 10px; border-right: 1px solid var(--rand); background: var(--panel); font-size: 13px; }
.guide-nav .werkzeuge { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
.guide-nav .meta { color: var(--dim); font-size: 12px; margin-bottom: 10px; }
.guide-nav a.kap { display: flex; align-items: baseline; gap: 6px; padding: 6px 8px; border-radius: 6px; cursor: pointer; color: var(--text); text-decoration: none; font-weight: 600; margin-top: 6px; }
.guide-nav a.kap .pfeil { color: var(--dim); font-size: 10px; width: 10px; flex: none; }
.guide-nav a.kap .t { flex: 1; }
.guide-nav a.abs { display: flex; justify-content: space-between; gap: 6px; padding: 3px 8px 3px 24px; border-radius: 6px; cursor: pointer; color: var(--dim); text-decoration: none; }
.guide-nav a.aktiv { background: var(--karte); color: var(--text); }
.guide-nav .zeit { color: var(--dim); font-size: 11px; white-space: nowrap; }
.guide-nav .fortschritt { height: 4px; background: var(--karte); border-radius: 2px; margin: 8px 0 4px; overflow: hidden; }
.guide-nav .fortschritt i { display: block; height: 100%; background: var(--gruen); }
.guide-nav .leer { color: var(--dim); padding: 8px; }
.guide-nav .teil { margin: 14px 0 2px; font: 600 11px system-ui, sans-serif; letter-spacing: 0.08em; text-transform: uppercase; color: var(--dim); }
.lese { flex: 1; overflow-y: auto; background: var(--lese-bg); color: var(--lese-text); }
.buch { max-width: 68ch; margin: 0 auto; padding: 36px 28px 120px; font-family: "Charter", "Bitstream Charter", "Iowan Old Style", "Palatino Linotype", Georgia, serif; font-size: 1.125rem; line-height: 1.65; hyphens: auto; hyphenate-limit-chars: 8 4 4; }
.buch h1 { font-size: 2rem; line-height: 1.2; margin: 0 0 10px; }
.buch .untertitel { color: var(--lese-text); font-style: italic; font-size: 1.1rem; margin-bottom: 6px; }
.buch .meta { color: var(--lese-dim); margin-bottom: 26px; font-size: 0.95rem; }
.buch .vorwort { margin-top: 8px; }
.buch .vorwort h2 { font-size: 1.3rem; margin-bottom: 14px; }
.buch .kapitel { margin-top: 56px; padding-top: 18px; border-top: 1px solid var(--lese-rand); }
.buch .kapitel:first-of-type { border-top: 0; margin-top: 8px; }
.buch h2 { font-size: 1.6rem; line-height: 1.25; margin: 0 0 6px; }
.buch h2 .badge { font: 11.5px system-ui, sans-serif; vertical-align: middle; margin-left: 10px; }
.buch .kap-meta { color: var(--lese-dim); font-size: 0.85rem; margin-bottom: 14px; }
.buch .kap-intro { color: var(--lese-text); margin-bottom: 24px; }
.buch h3 { font-size: 1.22rem; margin: 34px 0 4px; line-height: 1.3; scroll-margin-top: 16px; }
.buch p { margin: 0 0 1em; }
.buch ul, .buch ol { padding-left: 1.4em; margin: 0 0 1em; }
.buch li { margin: 0.25em 0; }
.buch code { font-family: "SF Mono", Consolas, Menlo, monospace; font-size: 0.86em; background: var(--code-bg); padding: 1px 5px; border-radius: 4px; overflow-wrap: anywhere; }
.buch p, .buch li { overflow-wrap: anywhere; }
.buch pre { background: var(--code-bg); color: var(--code-fg); padding: 12px 14px; border-radius: 8px; overflow-x: auto; font-size: 0.86em; line-height: 1.5; }
.buch pre code { background: none; padding: 0; }
.buch table { border-collapse: collapse; margin: 0 0 1em; font-size: 0.92em; display: block; max-width: 100%; overflow-x: auto; }
.buch th, .buch td { border: 1px solid var(--lese-rand); padding: 4px 9px; text-align: left; }
.buch .hinweis { background: var(--karte); border-radius: 6px; padding: 8px 12px; font-size: 0.85rem; color: var(--dim); font-family: system-ui, sans-serif; margin: 0 0 14px; }
.buch .teil-titel { font: 600 0.78rem system-ui, sans-serif; letter-spacing: 0.1em; text-transform: uppercase; color: var(--lese-dim); margin: 40px 0 6px; }
/* Prüfung je Kapitel (Autoren- und Prüfbericht), eingeklappt */
.buch .pruefung { margin-top: 28px; border: 1px solid var(--lese-rand); border-radius: 8px; font-family: system-ui, sans-serif; }
.buch .pruefung summary { padding: 8px 12px; cursor: pointer; color: var(--lese-dim); font-size: 0.85rem; font-weight: 600; }
.buch .pruefung h4 { margin: 10px 12px 4px; font-size: 0.78rem; color: var(--lese-dim); text-transform: uppercase; letter-spacing: 0.06em; }
.buch .pruefung .md { padding: 0 12px 10px; }
.buch .ende { margin-top: 60px; border-top: 1px solid var(--lese-rand); padding-top: 16px; font-family: system-ui, sans-serif; }
.buch .ende details { margin-bottom: 8px; }
.buch .ende summary { cursor: pointer; font-weight: 600; font-size: 0.9rem; padding: 6px 0; }
.buch .ende .md { padding: 4px 0 8px; }
.mobil-leiste { display: none; }
/* Lesemodus (Overlay, Bildschirm für Bildschirm) */
.lesemodus { position: fixed; inset: 0; z-index: 40; background: var(--lese-bg); color: var(--lese-text); display: flex; flex-direction: column; }
.lm-kopf { display: flex; align-items: center; gap: 12px; padding: 10px 18px; border-bottom: 1px solid var(--lese-rand); font: 12.5px system-ui, sans-serif; color: var(--lese-dim); }
.lm-kopf .balken { flex: 1; height: 4px; background: var(--karte); border-radius: 2px; overflow: hidden; }
.lm-kopf .balken i { display: block; height: 100%; background: var(--gruen); }
.lm-kopf button { background: transparent; color: var(--lese-dim); font-size: 18px; padding: 2px 8px; }
.lm-buehne { flex: 1; overflow-y: auto; }
.lm-screen { max-width: 66ch; margin: 0 auto; padding: 40px 28px 80px; font-family: "Charter", "Bitstream Charter", "Iowan Old Style", "Palatino Linotype", Georgia, serif; font-size: 1.15rem; line-height: 1.65; hyphens: auto; hyphenate-limit-chars: 8 4 4; }
.lm-label { font: 600 11.5px system-ui, sans-serif; letter-spacing: 0.08em; text-transform: uppercase; color: var(--lese-dim); margin-bottom: 8px; }
.lm-screen h1 { font-size: 2.1rem; line-height: 1.2; margin: 0 0 12px; }
.lm-screen h2 { font-size: 1.6rem; line-height: 1.25; margin: 0 0 6px; }
.lm-screen .intro { color: var(--lese-text); }
.lm-screen .stats { color: var(--lese-dim); font: 13px system-ui, sans-serif; margin-top: 14px; }
.lm-screen p { margin: 0 0 1em; }
.lm-screen code { font-family: "SF Mono", Consolas, monospace; font-size: 0.86em; background: var(--code-bg); padding: 1px 5px; border-radius: 4px; }
.lm-screen pre { background: var(--code-bg); color: var(--code-fg); padding: 12px 14px; border-radius: 8px; overflow-x: auto; font-size: 0.86em; }
.lm-screen pre code { background: none; padding: 0; }
.lm-screen ul, .lm-screen ol { padding-left: 1.4em; }
.lm-screen table { border-collapse: collapse; margin: 0 0 1em; font-size: 0.92em; display: block; max-width: 100%; overflow-x: auto; }
.lm-screen th, .lm-screen td { border: 1px solid var(--lese-rand); padding: 4px 9px; text-align: left; }
.lm-screen.ende { text-align: center; padding-top: 120px; }
.lm-fuss { display: flex; justify-content: space-between; padding: 10px 18px; border-top: 1px solid var(--lese-rand); font-family: system-ui, sans-serif; }
.lm-fuss button { background: var(--karte); color: var(--text); border: 1px solid var(--rand); }
.lm-fuss button.weiter { background: var(--akzent); color: #fff; border: 0; }
kbd { font: 11px system-ui, sans-serif; border: 1px solid var(--rand); border-radius: 4px; padding: 0 5px; margin-left: 6px; color: var(--dim); }
@media (max-width: 800px) {
.topbar { gap: 8px; padding: 8px 10px; flex-wrap: nowrap; }
.topbar .badge { display: none; }
.topbar .titel { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.mobil-leiste { display: flex; gap: 8px; padding: 10px 16px 0; align-items: center; }
.guide-nav.offen .schliessen { display: inline-block; margin: 10px 10px 4px; }
.lese { overflow-x: hidden; }
.sidebar:not(.offen) { display: none; }
.burger { display: inline-block; }
.guide-nav { display: none; }
.guide-nav.offen { display: block; position: fixed; inset: 0 auto 0 0; z-index: 25; width: 82vw; }
.buch { padding: 22px 16px 100px; font-size: 1.02rem; }
.lm-screen { padding: 24px 16px 60px; font-size: 1.05rem; }
.lm-kopf, .lm-fuss { padding: 8px 12px; }
.stufe { grid-template-columns: 26px 1fr; }
.stufe .rechts { grid-column: 2; }
.ereignis { grid-template-columns: 64px 1fr; }
.ereignis .text { grid-column: 1 / -1; }
}
/* Code-Farben je Theme (statt einer festen highlight.js-Vorlage) */
pre.hljs, .hljs { background: var(--code-bg); color: var(--code-fg); }
.hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-meta { color: var(--c-kw); }
.hljs-string, .hljs-regexp, .hljs-addition { color: var(--c-str); }
.hljs-comment, .hljs-quote { color: var(--c-com); font-style: italic; }
.hljs-number, .hljs-symbol, .hljs-bullet { color: var(--c-num); }
.hljs-title, .hljs-function, .hljs-section, .hljs-name { color: var(--c-fn); }
.hljs-built_in, .hljs-type, .hljs-class, .hljs-attr, .hljs-variable { color: var(--c-type); }

7
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: { proxy: { '/api': 'http://localhost:8020' } },
})