231 lines
8.7 KiB
Vue
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>
|