init
This commit is contained in:
185
frontend/src/App.vue
Normal file
185
frontend/src/App.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user