Files
creator/frontend/src/App.vue
2026-06-07 11:29:04 +02:00

498 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>