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>
214 lines
10 KiB
Vue
214 lines
10 KiB
Vue
<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>
|