Files
buchwerk/frontend/src/App.vue
team3 7538812cfd Spezialisierung je Thema: begrenzt den Umfang auf einen Ausschnitt
Drittes Feld neben Thema und Hinweisen. Hinweise fügen hinzu, die
Spezialisierung nimmt weg: Recherche, Gliederung, Kapitel und Prüfung
bekommen sie als verbindliche Grenze; nur Stoff, der dafür gebraucht
wird, Grundlagen nur soweit nötig.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 15:20:10 +02:00

214 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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'
// Adresse: /<slug>[/v<N>][/lauf|/buch|/lesen][/<Abschnitt>] – ohne Version gilt die neueste
const TABS = ['lauf', 'buch', 'lesen']
function routeLesen() {
const t = location.pathname.split('/').filter(Boolean).map(decodeURIComponent)
const r = { slug: t[0] || '', v: null, tab: 'lauf', nr: '' }
let i = 1
if (/^v\d+$/.test(t[i] || '')) { r.v = Number(t[i].slice(1)); i++ }
if (TABS.includes(t[i] || '')) { r.tab = t[i]; i++ }
r.nr = t[i] || ''
return r
}
function pfadVon(r) {
if (!r.slug) return '/'
let p = '/' + encodeURIComponent(r.slug)
if (r.v) p += '/v' + r.v
p += '/' + r.tab
if (r.tab !== 'lauf' && r.nr) p += '/' + encodeURIComponent(r.nr)
return p
}
const start = routeLesen()
const topics = ref([])
const slug = ref(start.slug || localStorage.getItem('slug') || '')
const version = ref(start.v) // null = neueste Version
const tab = ref(start.tab)
const nr = ref(start.nr) // Abschnitt im Buch, z. B. 3.4 oder „vorwort“
const theme = ref(localStorage.getItem('theme') || 'papier')
const state = ref(null)
const fehler = ref('')
const neuName = ref('')
const neuHinweise = ref('')
const neuSpezialisierung = 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 === 'nacht' ? 'nacht' : theme.value === 'papier' ? 'sepia' : hell ? 'light' : 'dark'
localStorage.setItem('theme', theme.value)
}
watch(theme, themeAnwenden)
media.addEventListener('change', themeAnwenden)
themeAnwenden()
// Navigation: pushState für Klicks (Zurück-Taste funktioniert), replaceState beim Scrollen
function geheZu(r, ersetzen = false) {
const ziel = { slug: slug.value, v: version.value, tab: tab.value, nr: nr.value, ...r }
if (ziel.tab === 'lauf') ziel.nr = ''
const pfad = pfadVon(ziel)
if (pfad !== location.pathname) history[ersetzen ? 'replaceState' : 'pushState'](null, '', pfad)
slug.value = ziel.slug; version.value = ziel.v; tab.value = ziel.tab; nr.value = ziel.nr
}
function popstate() { const r = routeLesen(); slug.value = r.slug; version.value = r.v; tab.value = r.tab; nr.value = r.nr }
watch(slug, (sl) => { if (sl) localStorage.setItem('slug', sl) })
const aktuell = computed(() => topics.value.find((t) => t.slug === slug.value))
const versionen = computed(() => aktuell.value?.versionen || [])
const neueste = computed(() => versionen.value.length ? versionen.value[versionen.value.length - 1].v : null)
const versionAktiv = computed(() => version.value || neueste.value)
watch([aktuell, tab, versionAktiv], ([a, t, v]) => {
if (t === 'lauf') document.title = a ? `${a.titel}${versionen.value.length > 1 ? ' v' + v : ''} – buchwerk` : 'buchwerk'
}, { immediate: true })
function datum(iso) { return iso ? new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' }) : '' }
function themaWaehlen(sl) { geheZu({ slug: sl, v: null, tab: 'lauf' }); navAuf.value = false }
function versionWaehlen(sl, v) { geheZu({ slug: sl, v, tab: 'buch', nr: '' }); navAuf.value = false }
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.themen()
fehler.value = ''
if (slug.value && !topics.value.some((t) => t.slug === slug.value)) slug.value = ''
if (!slug.value && topics.value.length) geheZu({ slug: topics.value[0].slug, v: null, tab: 'lauf' }, true)
if (version.value && !versionen.value.some((x) => x.v === version.value)) version.value = null
} 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 (!slug.value || !versionAktiv.value) { state.value = null; return }
try { state.value = await api.state(slug.value, versionAktiv.value) } catch (e) { fehler.value = String(e.message || e) }
}
function pollen() {
clearInterval(timer)
timer = setInterval(async () => {
if (!slug.value) return
await stateLaden()
if (!laeuft.value) { clearInterval(timer); timer = null; await topicsLaden() }
}, 2000)
}
watch([slug, versionAktiv], 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 {
const t = await api.anlegen(name, neuHinweise.value.trim(), neuSpezialisierung.value.trim())
neuName.value = ''
neuHinweise.value = ''
neuSpezialisierung.value = ''
await topicsLaden()
themaWaehlen(t.slug)
} catch (e) { fehler.value = String(e.message || e) }
}
// Neue Version: gleiche Eingaben, frischer Lauf; die alte Version bleibt samt Leseposition erhalten.
async function neueVersion() {
try {
const r = await api.versionAnlegen(slug.value)
await topicsLaden()
geheZu({ v: r.v, tab: 'lauf' })
await aktion(() => api.start(slug.value, r.v))
} 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(t) {
if (!confirm(`Thema „${t.titel}“ löschen?`)) return
await aktion(() => api.loeschen(t.slug))
}
function esc(e) { if (e.key === 'Escape') navAuf.value = false }
onMounted(async () => {
window.addEventListener('keydown', esc); window.addEventListener('popstate', popstate)
await topicsLaden(); await stateLaden(); if (laeuft.value) pollen()
})
onUnmounted(() => { clearInterval(timer); window.removeEventListener('keydown', esc); window.removeEventListener('popstate', popstate) })
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 !== 'lauf' }">
<a class="skip" href="#inhalt">Zum Inhalt springen</a>
<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">
<template v-for="t in topics" :key="t.slug">
<div class="topic-eintrag" :class="{ aktiv: t.slug === slug && (t.versionen.length === 1 || tab === 'lauf') }"
@click="themaWaehlen(t.slug)">
<span class="punkt" :class="t.laeuft ? 'laeuft' : t.status"></span>
<span style="flex: 1">{{ t.titel }}</span>
<button v-if="t.versionen.length === 1 && t.status !== 'fertig'" class="klein sekundaer" title="Löschen" @click.stop="loeschen(t)">✕</button>
</div>
<div v-if="t.versionen.length > 1" class="versionen">
<div v-for="x in t.versionen" :key="x.v" class="topic-eintrag version"
:class="{ aktiv: t.slug === slug && tab !== 'lauf' && x.v === versionAktiv }" @click="versionWaehlen(t.slug, x.v)">
<span class="punkt" :class="x.laeuft ? 'laeuft' : x.status"></span>
<span style="flex: 1">v{{ x.v }} <span class="dim">· {{ datum(x.angelegt) }}</span></span>
</div>
</div>
</template>
<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" />
<input v-model="neuSpezialisierung" placeholder="Spezialisierung (optional), z. B. Kartenspiel gegen KI" title="Begrenzt den Umfang: nur, was dafür gebraucht wird" @keyup.enter="anlegen" />
<button @click="anlegen">Anlegen</button>
</div>
</aside>
<div class="haupt">
<header 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="geheZu({ tab: 'lauf' })">Lauf</button>
<button :class="{ aktiv: tab !== 'lauf' }" @click="geheZu({ 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="nacht">nacht</option>
<option value="hell">hell</option>
<option value="auto">auto</option>
</select>
</header>
<div v-if="fehler" class="fehler" @click="fehler = ''">{{ fehler }} — klicken zum Schließen</div>
<main class="inhalt" id="inhalt" tabindex="-1">
<Lauf v-if="tab === 'lauf' && state" :state="state"
@start="aktion(() => api.start(slug, versionAktiv))"
@pause="aktion(() => api.pause(slug, versionAktiv))"
@reset="aktion(() => api.reset(slug, versionAktiv))"
@neue-version="neueVersion" />
<Guide v-else-if="tab !== 'lauf' && slug && versionAktiv" :slug="slug" :version="versionAktiv" :tab="tab" :nr="nr"
:laeuft="laeuft" :fortschritt="fortschritt"
@tab="(t) => geheZu({ tab: t })" @nr="(e) => geheZu({ nr: e.nr }, !e.push)" />
<div v-else style="padding: 24px; color: var(--dim)">Thema links anlegen oder wählen.</div>
</main>
</div>
</div>
</template>