init
This commit is contained in:
153
frontend/src/App.vue
Normal file
153
frontend/src/App.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<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'
|
||||
|
||||
const params = new URLSearchParams(location.search)
|
||||
const topics = ref([])
|
||||
const topic = ref(params.get('topic') || localStorage.getItem('topic') || '')
|
||||
const tab = ref((params.get('tab') || localStorage.getItem('tab')) === 'buch' ? 'buch' : 'lauf')
|
||||
const theme = ref(localStorage.getItem('theme') || 'papier')
|
||||
const state = ref(null)
|
||||
const fehler = ref('')
|
||||
const neuName = ref('')
|
||||
const neuHinweise = 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 === 'papier' ? 'sepia' : hell ? 'light' : 'dark'
|
||||
localStorage.setItem('theme', theme.value)
|
||||
}
|
||||
watch(theme, themeAnwenden)
|
||||
media.addEventListener('change', themeAnwenden)
|
||||
themeAnwenden()
|
||||
|
||||
// URL spiegelt Thema und Tab; beim Themenwechsel fällt der Deep-Link (#a…/#k…) weg
|
||||
function urlSchreiben(hashBehalten) {
|
||||
const p = new URLSearchParams()
|
||||
if (topic.value) p.set('topic', topic.value)
|
||||
p.set('tab', tab.value)
|
||||
history.replaceState(null, '', location.pathname + '?' + p + (hashBehalten ? location.hash : ''))
|
||||
}
|
||||
watch(topic, (t) => { localStorage.setItem('topic', t); urlSchreiben(false) })
|
||||
watch(tab, (t) => { localStorage.setItem('tab', t); urlSchreiben(true) })
|
||||
|
||||
const aktuell = computed(() => topics.value.find((t) => t.name === topic.value))
|
||||
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.topics()
|
||||
fehler.value = ''
|
||||
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
|
||||
} 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 (!topic.value) { state.value = null; return }
|
||||
try { state.value = await api.state(topic.value) } catch (e) { fehler.value = String(e.message || e) }
|
||||
}
|
||||
function pollen() {
|
||||
clearInterval(timer)
|
||||
timer = setInterval(async () => {
|
||||
if (!topic.value) return
|
||||
await stateLaden()
|
||||
if (!laeuft.value) { clearInterval(timer); timer = null; await topicsLaden() }
|
||||
}, 2000)
|
||||
}
|
||||
watch(topic, 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 {
|
||||
await api.anlegen(name, neuHinweise.value.trim())
|
||||
neuName.value = ''
|
||||
neuHinweise.value = ''
|
||||
await topicsLaden()
|
||||
topic.value = name
|
||||
tab.value = 'lauf'
|
||||
} 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(name) {
|
||||
if (!confirm(`Thema „${name}“ samt Buch löschen?`)) return
|
||||
await aktion(() => api.loeschen(name))
|
||||
}
|
||||
|
||||
function esc(e) { if (e.key === 'Escape') navAuf.value = false }
|
||||
onMounted(async () => { window.addEventListener('keydown', esc); urlSchreiben(true); await topicsLaden(); await stateLaden(); if (laeuft.value) pollen() })
|
||||
onUnmounted(() => { clearInterval(timer); window.removeEventListener('keydown', esc) })
|
||||
|
||||
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 === 'buch' }">
|
||||
<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">
|
||||
<div v-for="t in topics" :key="t.name" class="topic-eintrag" :class="{ aktiv: t.name === topic }"
|
||||
@click="topic = t.name; navAuf = false">
|
||||
<span class="punkt" :class="t.laeuft ? 'laeuft' : t.status"></span>
|
||||
<span style="flex: 1">{{ t.titel }}</span>
|
||||
<button class="klein sekundaer" title="Löschen" @click.stop="loeschen(t.name)">✕</button>
|
||||
</div>
|
||||
<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" />
|
||||
<button @click="anlegen">Anlegen</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="haupt">
|
||||
<div 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="tab = 'lauf'">Lauf</button>
|
||||
<button :class="{ aktiv: tab === 'buch' }" @click="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="hell">hell</option>
|
||||
<option value="auto">auto</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="fehler" class="fehler" @click="fehler = ''">{{ fehler }} — klicken zum Schließen</div>
|
||||
<div class="inhalt">
|
||||
<Lauf v-if="tab === 'lauf' && state" :state="state"
|
||||
@start="aktion(() => api.start(topic))"
|
||||
@pause="aktion(() => api.pause(topic))"
|
||||
@reset="aktion(() => api.reset(topic))" />
|
||||
<Guide v-else-if="tab === 'buch' && topic" :topic="topic" :laeuft="laeuft" :fortschritt="fortschritt" />
|
||||
<div v-else style="padding: 24px; color: var(--dim)">Thema links anlegen oder wählen.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user