186 lines
6.2 KiB
Vue
186 lines
6.2 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('')
|
|
const state = ref(null)
|
|
const tab = ref('board')
|
|
const neuName = ref('')
|
|
const neuArt = ref('thema')
|
|
const fehler = ref('')
|
|
const theme = ref(localStorage.getItem('theme') || 'auto')
|
|
const navAuf = ref(localStorage.getItem('navAuf') !== '0')
|
|
|
|
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 })
|
|
|
|
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()
|
|
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() {
|
|
await api.stop(topic.value)
|
|
await stateLaden()
|
|
}
|
|
|
|
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()
|
|
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>
|
|
<button @click="starten" :disabled="!topic || state?.run?.status === 'running'">Start</button>
|
|
<button class="sekundaer" @click="stoppen" :disabled="state?.run?.status !== 'running'">Stop</button>
|
|
<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>
|
|
<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" />
|
|
<Guide v-else-if="tab === 'guide'" :topic="topic" :state="state" />
|
|
<Ueben v-else-if="tab === 'üben'" :topic="topic" />
|
|
<Kennzahlen v-else :run="state?.run" />
|
|
</div>
|
|
</div>
|
|
</template>
|