init
This commit is contained in:
153
frontend/src/App.vue
Normal file
153
frontend/src/App.vue
Normal 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
28
frontend/src/api.js
Normal 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`,
|
||||
}
|
||||
174
frontend/src/components/Guide.vue
Normal file
174
frontend/src/components/Guide.vue
Normal 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>
|
||||
105
frontend/src/components/Lauf.vue
Normal file
105
frontend/src/components/Lauf.vue
Normal 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>
|
||||
107
frontend/src/components/Lesemodus.vue
Normal file
107
frontend/src/components/Lesemodus.vue
Normal 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
5
frontend/src/main.js
Normal 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
48
frontend/src/markdown.js
Normal 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
227
frontend/src/style.css
Normal 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); }
|
||||
Reference in New Issue
Block a user