543 lines
14 KiB
Vue
543 lines
14 KiB
Vue
<script setup>
|
||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||
import { fetchGuides, fetchTopics, createTopic as apiCreateTopic, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBausteineStatus, fetchActiveBausteine, createBausteine as apiCreateBausteine, cancelBausteine as apiCancelBausteine, deleteBausteine as apiDeleteBausteine, fetchProjects, deleteProject as apiDeleteProject, fetchProviders, fetchStats, fetchTopicFortschritt } from './api.js'
|
||
import TopicSidebar from './components/TopicSidebar.vue'
|
||
import TopicDetail from './components/TopicDetail.vue'
|
||
import ElementsSidebar from './components/ElementsSidebar.vue'
|
||
import ElementsOverview from './components/ElementsOverview.vue'
|
||
|
||
const guides = ref([])
|
||
const projects = ref([])
|
||
const backendTopics = ref([])
|
||
const selectedTopic = ref(null)
|
||
const previewGuide = ref(null)
|
||
const sidebarPinned = ref(localStorage.getItem('sidebarPinned') !== 'false')
|
||
const sidebarSticky = ref(false)
|
||
const darkMode = ref(
|
||
localStorage.getItem('darkMode') === null
|
||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||
: localStorage.getItem('darkMode') === 'true',
|
||
)
|
||
const EMPTY_BAUSTEINE = { ready: false, generating: false, progress: null, error: null, partial: false, steps: [] }
|
||
const bausteine = ref({ ...EMPTY_BAUSTEINE })
|
||
const activeBausteine = ref([])
|
||
const provider = ref(localStorage.getItem('provider') || 'claude')
|
||
const providers = ref([])
|
||
const stats = ref(null)
|
||
const fortschritt = ref({})
|
||
const elementsOpen = ref(false) // rechte Sidebar
|
||
const elementsView = ref(false) // Übersicht im Hauptbereich
|
||
const elementsVersion = ref(0) // Erhöhung = Übersicht neu laden
|
||
const elementOpenId = ref(null) // Element aus Übersicht in Sidebar öffnen
|
||
const elementOpenTick = ref(0)
|
||
|
||
async function loadStats() {
|
||
try {
|
||
stats.value = await fetchStats()
|
||
} catch (e) {
|
||
console.error('Fehler beim Laden der Statistik:', e)
|
||
}
|
||
}
|
||
|
||
function setProvider(id) {
|
||
provider.value = id
|
||
localStorage.setItem('provider', id)
|
||
}
|
||
|
||
async function loadProviders() {
|
||
try {
|
||
providers.value = await fetchProviders()
|
||
const current = providers.value.find((p) => p.id === provider.value)
|
||
if (current && !current.available) {
|
||
const fallback = providers.value.find((p) => p.available)
|
||
if (fallback) setProvider(fallback.id)
|
||
}
|
||
} catch (e) {
|
||
console.error('Fehler beim Laden der Provider:', e)
|
||
}
|
||
}
|
||
let pollTimer = null
|
||
|
||
function applyTheme() {
|
||
document.documentElement.classList.toggle('dark', darkMode.value)
|
||
}
|
||
|
||
function toggleDark() {
|
||
darkMode.value = !darkMode.value
|
||
localStorage.setItem('darkMode', darkMode.value ? 'true' : 'false')
|
||
applyTheme()
|
||
}
|
||
|
||
applyTheme()
|
||
|
||
function toggleSidebarPin() {
|
||
sidebarPinned.value = !sidebarPinned.value
|
||
localStorage.setItem('sidebarPinned', sidebarPinned.value ? 'true' : 'false')
|
||
if (sidebarPinned.value) sidebarSticky.value = false
|
||
}
|
||
|
||
function clickHoverZone() {
|
||
sidebarSticky.value = !sidebarSticky.value
|
||
}
|
||
|
||
function onSidebarLeave() {
|
||
if (!sidebarPinned.value) sidebarSticky.value = false
|
||
}
|
||
|
||
const projectNames = computed(() => projects.value.map((p) => p.name))
|
||
|
||
const topics = computed(() => {
|
||
const isProject = new Set(projectNames.value)
|
||
return backendTopics.value.filter((t) => !isProject.has(t))
|
||
})
|
||
|
||
const doneByFormat = computed(() => {
|
||
const map = {}
|
||
for (const g of guides.value) {
|
||
if (g.topic !== selectedTopic.value) continue
|
||
if (g.status !== 'done') continue
|
||
if (!map[g.format] || g.created_at > map[g.format].created_at) {
|
||
map[g.format] = g
|
||
}
|
||
}
|
||
return map
|
||
})
|
||
|
||
const latestByFormat = computed(() => {
|
||
const map = {}
|
||
for (const g of guides.value) {
|
||
if (g.topic !== selectedTopic.value) continue
|
||
if (!map[g.format] || g.created_at > map[g.format].created_at) {
|
||
map[g.format] = g
|
||
}
|
||
}
|
||
return map
|
||
})
|
||
|
||
const hasActiveGuides = computed(() =>
|
||
guides.value.some((g) => g.status === 'queued' || g.status === 'generating'),
|
||
)
|
||
|
||
async function loadTopics() {
|
||
try {
|
||
backendTopics.value = await fetchTopics()
|
||
} catch (e) {
|
||
console.error('Fehler beim Laden der Themen:', e)
|
||
}
|
||
}
|
||
|
||
// Fehlermeldungen verhalten sich wie Flash-Messages: × blendet aus,
|
||
// beim Reload sind Alt-Fehler von vornherein ausgeblendet.
|
||
const dismissedErrors = ref(new Set())
|
||
let errorsInitialized = false
|
||
|
||
function handleDismissError(guideId) {
|
||
dismissedErrors.value = new Set([...dismissedErrors.value, guideId])
|
||
}
|
||
|
||
async function loadGuides() {
|
||
try {
|
||
guides.value = await fetchGuides()
|
||
if (!errorsInitialized) {
|
||
errorsInitialized = true
|
||
dismissedErrors.value = new Set(guides.value.filter((g) => g.status === 'error').map((g) => g.id))
|
||
}
|
||
loadStats()
|
||
} catch (e) {
|
||
console.error('Fehler beim Laden:', e)
|
||
}
|
||
}
|
||
|
||
async function loadBausteine() {
|
||
try {
|
||
activeBausteine.value = await fetchActiveBausteine()
|
||
if (selectedTopic.value) {
|
||
bausteine.value = await fetchBausteineStatus(selectedTopic.value)
|
||
fortschritt.value = await fetchTopicFortschritt(selectedTopic.value)
|
||
} else {
|
||
bausteine.value = { ...EMPTY_BAUSTEINE }
|
||
fortschritt.value = {}
|
||
}
|
||
if (activeBausteine.value.length && !pollTimer) startPolling()
|
||
} catch (e) {
|
||
console.error('Fehler beim Laden der Bausteine:', e)
|
||
}
|
||
}
|
||
|
||
async function loadProjects() {
|
||
try {
|
||
projects.value = await fetchProjects()
|
||
} catch (e) {
|
||
console.error('Fehler beim Laden der Projekte:', e)
|
||
}
|
||
}
|
||
|
||
const FORMAT_ORDER = ['OnePager', 'MiniGuide', 'Guide', 'FullGuide']
|
||
|
||
function autoPreview() {
|
||
const map = doneByFormat.value
|
||
for (const f of FORMAT_ORDER) {
|
||
if (map[f]) {
|
||
previewGuide.value = map[f]
|
||
return
|
||
}
|
||
}
|
||
previewGuide.value = null
|
||
}
|
||
|
||
function selectTopic(topic) {
|
||
selectedTopic.value = topic
|
||
previewGuide.value = null
|
||
sidebarSticky.value = false
|
||
elementsOpen.value = false
|
||
elementsView.value = false
|
||
elementOpenId.value = null
|
||
localStorage.setItem('lastTopic', topic)
|
||
loadBausteine()
|
||
nextTick(autoPreview)
|
||
}
|
||
|
||
// Beim Reload dort landen, wo man vorher war (Thema + Format)
|
||
watch(previewGuide, (g) => {
|
||
if (g) localStorage.setItem('lastFormat', g.format)
|
||
})
|
||
|
||
async function createTopic(topic) {
|
||
await apiCreateTopic(topic)
|
||
await loadTopics()
|
||
selectedTopic.value = topic
|
||
previewGuide.value = null
|
||
loadBausteine()
|
||
}
|
||
|
||
async function handleCancelBausteine() {
|
||
if (!selectedTopic.value) return
|
||
await apiCancelBausteine(selectedTopic.value)
|
||
await loadBausteine()
|
||
}
|
||
|
||
async function handleResetBausteine() {
|
||
if (!selectedTopic.value) return
|
||
await apiDeleteBausteine(selectedTopic.value)
|
||
await loadBausteine()
|
||
}
|
||
|
||
async function handleBausteineClick({ instructions }) {
|
||
if (!selectedTopic.value) return
|
||
await apiCreateBausteine(selectedTopic.value, instructions, provider.value)
|
||
await loadBausteine()
|
||
startPolling()
|
||
}
|
||
|
||
async function handleFormatClick({ format, instructions }) {
|
||
if (!selectedTopic.value) return
|
||
// Kein Duplikat-Start: läuft für Thema+Format schon eine Generierung, ignorieren
|
||
const running = guides.value.some(
|
||
(g) => g.topic === selectedTopic.value && g.format === format
|
||
&& (g.status === 'generating' || g.status === 'queued'),
|
||
)
|
||
if (running) return
|
||
await apiCreate(selectedTopic.value, format, instructions, provider.value)
|
||
await loadGuides()
|
||
startPolling()
|
||
}
|
||
|
||
async function handleDeleteProject(name) {
|
||
await apiDeleteProject(name)
|
||
if (selectedTopic.value === name) {
|
||
selectedTopic.value = null
|
||
previewGuide.value = null
|
||
}
|
||
await loadProjects()
|
||
}
|
||
|
||
function handlePreview(guide) {
|
||
previewGuide.value = guide
|
||
elementsView.value = false
|
||
}
|
||
|
||
function handleOpenElements() {
|
||
if (!selectedTopic.value) return
|
||
elementsView.value = true
|
||
elementsOpen.value = true
|
||
}
|
||
|
||
function handleOpenElementDetail(el) {
|
||
elementOpenId.value = el.id
|
||
elementOpenTick.value++
|
||
elementsOpen.value = true
|
||
}
|
||
|
||
async function handleDeleteGuide(guideId, slots = false) {
|
||
await deleteGuide(guideId, slots)
|
||
if (previewGuide.value?.id === guideId) {
|
||
previewGuide.value = null
|
||
}
|
||
await loadGuides()
|
||
}
|
||
|
||
function startPolling() {
|
||
stopPolling()
|
||
pollTimer = setInterval(async () => {
|
||
await Promise.all([loadGuides(), loadBausteine(), loadTopics()])
|
||
if (!hasActiveGuides.value && !activeBausteine.value.length) stopPolling()
|
||
}, 3000)
|
||
}
|
||
|
||
function stopPolling() {
|
||
if (pollTimer) {
|
||
clearInterval(pollTimer)
|
||
pollTimer = null
|
||
}
|
||
}
|
||
|
||
async function handleCancel(guideId) {
|
||
await apiCancel(guideId)
|
||
await loadGuides()
|
||
}
|
||
|
||
async function handleDeleteTopic(topic) {
|
||
const topicGuides = guides.value.filter((g) => g.topic === topic)
|
||
for (const g of topicGuides) {
|
||
await deleteGuide(g.id)
|
||
}
|
||
await apiDeleteBausteine(topic)
|
||
await apiDeleteTopic(topic)
|
||
await loadTopics()
|
||
if (selectedTopic.value === topic) {
|
||
selectedTopic.value = null
|
||
previewGuide.value = null
|
||
}
|
||
await loadGuides()
|
||
}
|
||
|
||
function onVisibility() {
|
||
if (document.hidden) {
|
||
stopPolling()
|
||
} else {
|
||
loadGuides()
|
||
loadBausteine()
|
||
if (hasActiveGuides.value || activeBausteine.value.length) startPolling()
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await Promise.all([loadGuides(), loadTopics(), loadProjects(), loadProviders()])
|
||
const savedTopic = localStorage.getItem('lastTopic')
|
||
const savedFormat = localStorage.getItem('lastFormat')
|
||
if (savedTopic && [...topics.value, ...projectNames.value].includes(savedTopic)) {
|
||
selectTopic(savedTopic)
|
||
await nextTick()
|
||
const g = doneByFormat.value[savedFormat]
|
||
if (g) previewGuide.value = g
|
||
} else if (!selectedTopic.value && topics.value.length) {
|
||
selectTopic(topics.value[0])
|
||
}
|
||
document.addEventListener('visibilitychange', onVisibility)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
stopPolling()
|
||
document.removeEventListener('visibilitychange', onVisibility)
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="layout" :class="{ 'sidebar-floating': !sidebarPinned, 'sidebar-open': sidebarSticky }">
|
||
<div v-if="!sidebarPinned" class="hover-zone" @click="clickHoverZone"></div>
|
||
<div v-if="!sidebarPinned && sidebarSticky" class="sidebar-backdrop" @click="sidebarSticky = false"></div>
|
||
<TopicSidebar
|
||
:topics="topics"
|
||
:projects="projectNames"
|
||
:selectedTopic="selectedTopic"
|
||
:stats="stats"
|
||
:fortschritt="fortschritt"
|
||
:doneByFormat="doneByFormat"
|
||
:latestByFormat="latestByFormat"
|
||
:allGuides="guides"
|
||
:dismissedErrors="dismissedErrors"
|
||
:bausteine="bausteine"
|
||
:activeBausteine="activeBausteine"
|
||
:pinned="sidebarPinned"
|
||
:dark="darkMode"
|
||
:provider="provider"
|
||
:providers="providers"
|
||
@setProvider="setProvider"
|
||
@toggleDark="toggleDark"
|
||
@select="selectTopic"
|
||
@create="createTopic"
|
||
@formatClick="handleFormatClick"
|
||
@bausteineClick="handleBausteineClick"
|
||
@cancelBausteine="handleCancelBausteine"
|
||
@resetBausteine="handleResetBausteine"
|
||
@deleteTopic="handleDeleteTopic"
|
||
@deleteProject="handleDeleteProject"
|
||
@cancelGuide="handleCancel"
|
||
@deleteGuide="handleDeleteGuide"
|
||
@dismissError="handleDismissError"
|
||
@preview="handlePreview"
|
||
@openElements="handleOpenElements"
|
||
@togglePin="toggleSidebarPin"
|
||
@sidebarLeave="onSidebarLeave"
|
||
/>
|
||
<ElementsOverview
|
||
v-if="selectedTopic && elementsView"
|
||
:topic="selectedTopic"
|
||
:version="elementsVersion"
|
||
@open="handleOpenElementDetail"
|
||
/>
|
||
<TopicDetail
|
||
v-else-if="selectedTopic"
|
||
:previewGuide="previewGuide"
|
||
:dark="darkMode"
|
||
:provider="provider"
|
||
:elementsOpen="elementsOpen"
|
||
@progressChanged="loadStats(); loadBausteine()"
|
||
@openElements="elementsOpen = true"
|
||
/>
|
||
<div v-else class="empty-main">
|
||
<p>Thema in der Sidebar anlegen oder auswählen.</p>
|
||
</div>
|
||
<ElementsSidebar
|
||
v-if="elementsOpen && selectedTopic"
|
||
:topic="selectedTopic"
|
||
:provider="provider"
|
||
:openId="elementOpenId"
|
||
:openTick="elementOpenTick"
|
||
@close="elementsOpen = false"
|
||
@changed="elementsVersion++"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<style>
|
||
:root {
|
||
--bg: #f8f9fb;
|
||
--bg-preview: #f0f1f4;
|
||
--panel: #ffffff;
|
||
--panel-soft: #f4f5f7;
|
||
--border: #e2e5e9;
|
||
--border-strong: #d8dde3;
|
||
--text: #1a1a1a;
|
||
--text-muted: #4b5563;
|
||
--text-faint: #9ca3af;
|
||
--accent: #6366f1;
|
||
--accent-hover: #4f46e5;
|
||
--accent-soft: #ede9fe;
|
||
--accent-border: #a5b4fc;
|
||
--on-accent: #ffffff;
|
||
--success: #065f46;
|
||
--success-soft: #d1fae5;
|
||
--success-soft-hover: #a7f3d0;
|
||
--success-border: #34d399;
|
||
--warning: #92400e;
|
||
--warning-soft: #fef3c7;
|
||
--warning-border: #fbbf24;
|
||
--danger: #991b1b;
|
||
--code-bg: #1e2a3a;
|
||
--code-fg: #e6e6e6;
|
||
--shadow: rgba(0, 0, 0, 0.12);
|
||
}
|
||
|
||
html.dark {
|
||
--bg: #15171c;
|
||
--bg-preview: #0e1014;
|
||
--panel: #1c1f26;
|
||
--panel-soft: #23262e;
|
||
--border: #2c3038;
|
||
--border-strong: #3a3f4a;
|
||
--text: #e6e8ee;
|
||
--text-muted: #9aa3b2;
|
||
--text-faint: #6b7280;
|
||
--accent: #6366f1;
|
||
--accent-hover: #818cf8;
|
||
--accent-soft: #2a2350;
|
||
--accent-border: #4f46e5;
|
||
--on-accent: #ffffff;
|
||
--success: #34d399;
|
||
--success-soft: #0f3a2e;
|
||
--success-soft-hover: #155e45;
|
||
--success-border: #0f805e;
|
||
--warning: #fbbf24;
|
||
--warning-soft: #3a2c0a;
|
||
--warning-border: #a06a12;
|
||
--danger: #f87171;
|
||
--shadow: rgba(0, 0, 0, 0.5);
|
||
}
|
||
|
||
* {
|
||
box-sizing: border-box;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
body {
|
||
font-family: -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
}
|
||
|
||
input,
|
||
textarea {
|
||
background: var(--panel);
|
||
color: var(--text);
|
||
}
|
||
|
||
input::placeholder,
|
||
textarea::placeholder {
|
||
color: var(--text-faint);
|
||
}
|
||
|
||
.layout {
|
||
display: flex;
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
.hover-zone {
|
||
position: fixed;
|
||
left: 0;
|
||
top: 0;
|
||
width: 50px;
|
||
height: 100vh;
|
||
z-index: 5;
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* Unsichtbare Fläche hinter der offenen Floating-Sidebar.
|
||
Tipp/Klick daneben schließt sie — ohne sie gibt es auf Touch keinen Ausweg. */
|
||
.sidebar-backdrop {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 9;
|
||
background: transparent;
|
||
}
|
||
|
||
.layout.sidebar-floating > .sidebar {
|
||
position: fixed;
|
||
left: 0;
|
||
top: 0;
|
||
height: 100vh;
|
||
transform: translateX(-100%);
|
||
transition: transform 0.2s ease;
|
||
z-index: 10;
|
||
box-shadow: 0 0 16px var(--shadow);
|
||
}
|
||
|
||
.layout.sidebar-floating .hover-zone:hover ~ .sidebar,
|
||
.layout.sidebar-floating > .sidebar:hover,
|
||
.layout.sidebar-floating.sidebar-open > .sidebar {
|
||
transform: translateX(0);
|
||
}
|
||
|
||
.empty-main {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--text-muted);
|
||
font-size: 1rem;
|
||
}
|
||
</style>
|