Files
creator2/frontend/src/App.vue
2026-07-13 16:11:00 +02:00

231 lines
8.7 KiB
Vue

<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { api, wsVerbinden } from './api.js'
import Board from './components/Board.vue'
import Guide from './components/Guide.vue'
import Ueben from './components/Ueben.vue'
import Kennzahlen from './components/Kennzahlen.vue'
const topics = ref([])
const topic = ref(localStorage.getItem('topic') || '') // Auswahl über Reload halten
const state = ref(null)
const tab = ref(localStorage.getItem('tab') || 'board')
const neuName = ref('')
const neuArt = ref('thema')
const fehler = ref('')
const theme = ref(localStorage.getItem('theme') || 'auto')
const guideRef = ref(null) // Header-Play-Button startet den Lernmodus im Guide
// mobil (≤768px) startet die Navigation eingeklappt, sofern nie manuell gesetzt
const navAuf = ref(localStorage.getItem('navAuf') !== null
? localStorage.getItem('navAuf') !== '0'
: window.innerWidth > 768)
function navToggle() {
navAuf.value = !navAuf.value
localStorage.setItem('navAuf', navAuf.value ? '1' : '0')
}
watch(navAuf, (a) => document.body.classList.toggle('nav-zu', !a), { immediate: true })
// Ansicht über Reload halten: gewähltes Topic + Tab merken
watch(topic, (t) => localStorage.setItem('topic', t))
watch(tab, (t) => localStorage.setItem('tab', t))
let wsSchliessen = null
let timer = null
const media = window.matchMedia('(prefers-color-scheme: light)')
function themeAnwenden() {
const hell = theme.value === 'hell' || (theme.value === 'auto' && media.matches)
document.documentElement.dataset.theme =
theme.value === 'papier' ? 'sepia' : hell ? 'light' : 'dark'
localStorage.setItem('theme', theme.value)
}
watch(theme, themeAnwenden)
media.addEventListener('change', themeAnwenden)
async function topicsLaden() {
topics.value = await api.topics()
// gemerktes Topic gelöscht? → verwerfen, dann erst auf das erste zurückfallen
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
}
async function stateLaden() {
if (!topic.value) { state.value = null; return }
try {
state.value = await api.state(topic.value)
} catch (e) { fehler.value = String(e) }
}
function onWs() {
clearTimeout(timer)
timer = setTimeout(stateLaden, 400)
}
async function anlegen() {
if (!neuName.value.trim()) return
try {
await api.topicAnlegen({ name: neuName.value.trim(), art: neuArt.value })
const name = neuName.value.trim()
neuName.value = ''
await topicsLaden()
topic.value = name
await stateLaden()
} catch (e) { fehler.value = String(e) }
}
const menue = ref(null) // Kontextmenü: { x, y, name }
function menueOeffnen(e, name) {
menue.value = { x: e.clientX, y: e.clientY, name }
}
async function menueLoeschen() {
const name = menue.value?.name
menue.value = null
if (!name) return
try {
await api.topicLoeschen(name)
if (topic.value === name) topic.value = ''
await topicsLaden()
await stateLaden()
} catch (e) { fehler.value = String(e) }
}
async function starten() {
try { fehler.value = ''; await api.start(topic.value); await stateLaden() }
catch (e) { fehler.value = String(e) }
}
async function stoppen() {
try { fehler.value = ''; await api.stop(topic.value); await stateLaden() }
catch (e) { fehler.value = String(e) } // sonst läuft der Lauf weiter, ohne Signal
}
async function pausieren() {
try { fehler.value = ''; await api.pause(topic.value); await stateLaden() }
catch (e) { fehler.value = String(e) }
}
const transferOk = ref(false) // nur im lokalen Dev-Betrieb (Server: 403)
const uebertragung = ref('')
async function uebertragen(richtung) {
uebertragung.value = richtung === 'push' ? 'pusht' : 'pullt'
try {
fehler.value = ''
await api.transfer(topic.value, richtung)
await topicsLaden()
await stateLaden()
} catch (e) { fehler.value = String(e) }
finally { uebertragung.value = '' }
}
async function waehlen(name) {
topic.value = name
await stateLaden()
}
let poll = null // Fallback, falls während langer LLM-Calls keine WS-Deltas kommen
onMounted(async () => {
themeAnwenden()
api.transferInfo().then((i) => { transferOk.value = i.verfuegbar }).catch(() => {})
await topicsLaden()
await stateLaden()
wsSchliessen = wsVerbinden(onWs)
poll = setInterval(() => {
if (state.value?.run?.status === 'running') stateLaden()
}, 5000)
})
onUnmounted(() => {
wsSchliessen && wsSchliessen()
clearInterval(poll)
media.removeEventListener('change', themeAnwenden)
})
const tok = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : `${Math.round(n / 1000)}k`)
function runBadge(run) {
if (!run) return null
const farbe = { running: 'blau', done: 'gruen', paused: 'gelb', budget: 'gelb',
failed: 'rot', stopped: 'rot' }[run.status] || ''
return { farbe, text: run.status + (run.ebene && run.status === 'running' ? ` · ${run.ebene}` : '') }
}
</script>
<template>
<aside class="sidebar">
<div class="topics">
<div v-for="t in topics" :key="t.name" class="topic-eintrag"
:class="{ aktiv: t.name === topic }" @click="waehlen(t.name)"
@contextmenu.prevent="menueOeffnen($event, t.name)">
<span>{{ t.titel }}</span>
<span v-if="t.run?.status === 'running'" class="badge blau">läuft</span>
<span v-else-if="t.status === 'fertig'" class="badge gruen"></span>
</div>
</div>
<div class="neu">
<input v-model="neuName" placeholder="neues Thema…" @keyup.enter="anlegen" />
<div style="display: flex; gap: 6px">
<select v-model="neuArt" style="flex: 1">
<option value="thema">thema</option><option value="uni">uni</option>
</select>
<button class="sekundaer" @click="anlegen">Anlegen</button>
</div>
</div>
</aside>
<div v-if="menue" style="position: fixed; inset: 0; z-index: 99"
@click="menue = null" @contextmenu.prevent="menue = null">
<div class="kontextmenue" :style="{ left: menue.x + 'px', top: menue.y + 'px' }">
<button class="rot" @click.stop="menueLoeschen">Thema {{ menue.name }}" löschen</button>
</div>
</div>
<div class="hauptbereich">
<button class="nav-toggle" :title="navAuf ? 'Navigation einklappen' : 'Navigation anzeigen'"
@click="navToggle"></button>
<div class="topbar">
<div class="tabs">
<button v-for="t in ['board', 'guide', 'üben', 'kennzahlen']" :key="t"
:class="{ aktiv: tab === t }" @click="tab = t">{{ t }}</button>
</div>
<template v-if="tab === 'board'">
<button @click="starten" :disabled="!topic || state?.run?.status === 'running'">Start</button>
<button class="sekundaer" title="Wartende stoppen, Laufende laufen aus — Resume via Start"
@click="pausieren" :disabled="state?.run?.status !== 'running'">Pause</button>
<button class="sekundaer" @click="stoppen" :disabled="state?.run?.status !== 'running'">Stop</button>
</template>
<template v-if="transferOk && tab === 'board'">
<button class="sekundaer" title="Thema auf den Server pushen (überschreibt dort)"
:disabled="!topic || !!uebertragung || state?.run?.status === 'running'"
@click="uebertragen('push')"> Push</button>
<button class="sekundaer" title="Thema vom Server pullen (überschreibt lokal)"
:disabled="!topic || !!uebertragung || state?.run?.status === 'running'"
@click="uebertragen('pull')"> Pull</button>
<span v-if="uebertragung" class="badge blau">{{ uebertragung }}</span>
</template>
<span v-if="state?.run" class="badge" :class="runBadge(state.run).farbe">{{ runBadge(state.run).text }}</span>
<span v-if="state" class="badge">{{ tok(state.verbraucht) }} Tokens</span>
<span v-if="state?.run?.grund" class="badge gelb">{{ state.run.grund }}</span>
<span v-if="fehler" class="badge rot">{{ fehler }}</span>
<span style="flex: 1"></span>
<button v-if="tab === 'guide' && state" class="lm-start" title="Lernmodus (Vollbild)"
@click="guideRef?.lesemodusStarten()"></button>
<select v-model="theme" title="Farbschema">
<option value="hell">hell</option>
<option value="dunkel">dunkel</option>
<option value="papier">papier</option>
<option value="auto">auto</option>
</select>
</div>
<div class="inhalt">
<Board v-if="tab === 'board'" :state="state" :topic="topic" @reload="stateLaden"
@fehler="(e) => (fehler = e)" />
<Guide v-else-if="tab === 'guide'" ref="guideRef" :topic="topic" :state="state" />
<Ueben v-else-if="tab === 'üben'" :topic="topic" />
<Kennzahlen v-else :run="state?.run" />
</div>
</div>
</template>