498 lines
13 KiB
Vue
498 lines
13 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 } from './api.js'
|
||
import TopicSidebar from './components/TopicSidebar.vue'
|
||
import TopicDetail from './components/TopicDetail.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)
|
||
|
||
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)
|
||
} else {
|
||
bausteine.value = { ...EMPTY_BAUSTEINE }
|
||
}
|
||
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
|
||
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
|
||
}
|
||
|
||
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"
|
||
: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"
|
||
@togglePin="toggleSidebarPin"
|
||
@sidebarLeave="onSidebarLeave"
|
||
/>
|
||
<TopicDetail
|
||
v-if="selectedTopic"
|
||
:previewGuide="previewGuide"
|
||
:dark="darkMode"
|
||
:provider="provider"
|
||
@progressChanged="loadStats"
|
||
/>
|
||
<div v-else class="empty-main">
|
||
<p>Thema in der Sidebar anlegen oder auswählen.</p>
|
||
</div>
|
||
</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>
|