565 lines
15 KiB
Vue
565 lines
15 KiB
Vue
<script setup>
|
|
import { ref, computed, watch, onMounted, 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, fetchGuideLocks } from './api.js'
|
|
import { usePolling } from './composables/usePolling.js'
|
|
import TopicSidebar from './components/TopicSidebar.vue'
|
|
import TopicDetail from './components/TopicDetail.vue'
|
|
import ElementsSidebar from './components/elements/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 locks = ref({}) // Sperr-Gründe pro Format (Backend = einzige Regel-Quelle)
|
|
const uiError = ref(null) // abgewiesene Aktionen (409/400) sichtbar machen
|
|
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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Weggeklickte Fehler bleiben weggeklickt — auch über Reloads (localStorage).
|
|
// Nicht weggeklickte Fehler bleiben sichtbar, bis der Nutzer sie schließt.
|
|
const dismissedErrors = ref(new Set(JSON.parse(localStorage.getItem('dismissedErrors') || '[]')))
|
|
|
|
function persistDismissed() {
|
|
localStorage.setItem('dismissedErrors', JSON.stringify([...dismissedErrors.value]))
|
|
}
|
|
|
|
function handleDismissError(guideId) {
|
|
dismissedErrors.value = new Set([...dismissedErrors.value, guideId])
|
|
persistDismissed()
|
|
}
|
|
|
|
async function loadGuides() {
|
|
try {
|
|
guides.value = await fetchGuides()
|
|
// IDs prunen, deren Guide nicht mehr als Fehler existiert
|
|
const errorIds = new Set(guides.value.filter((g) => g.status === 'error').map((g) => g.id))
|
|
if ([...dismissedErrors.value].some((id) => !errorIds.has(id))) {
|
|
dismissedErrors.value = new Set([...dismissedErrors.value].filter((id) => errorIds.has(id)))
|
|
persistDismissed()
|
|
}
|
|
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)
|
|
locks.value = await fetchGuideLocks(selectedTopic.value)
|
|
} else {
|
|
bausteine.value = { ...EMPTY_BAUSTEINE }
|
|
fortschritt.value = {}
|
|
locks.value = {}
|
|
}
|
|
if (activeBausteine.value.length && !polling.running()) 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
|
|
uiError.value = null
|
|
try {
|
|
await apiCreateBausteine(selectedTopic.value, instructions, provider.value)
|
|
} catch (e) {
|
|
uiError.value = e.message
|
|
return
|
|
}
|
|
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
|
|
uiError.value = null
|
|
try {
|
|
await apiCreate(selectedTopic.value, format, instructions, provider.value)
|
|
} catch (e) {
|
|
uiError.value = e.message
|
|
return
|
|
}
|
|
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
|
|
// Rechte Sidebar bleibt zu — sie öffnet erst beim Klick auf ein Element.
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
const polling = usePolling(
|
|
() => Promise.all([loadGuides(), loadBausteine(), loadTopics()]),
|
|
() => hasActiveGuides.value || activeBausteine.value.length > 0,
|
|
)
|
|
const startPolling = polling.start
|
|
|
|
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()
|
|
}
|
|
|
|
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])
|
|
}
|
|
})
|
|
</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"
|
|
:locks="locks"
|
|
:uiError="uiError"
|
|
: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"
|
|
@dismissUiError="uiError = null"
|
|
@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"
|
|
:doneByFormat="doneByFormat"
|
|
:themaAbgeschlossen="!!fortschritt.abgeschlossen"
|
|
@progressChanged="loadStats(); loadBausteine()"
|
|
/>
|
|
<div v-else class="empty-main">
|
|
<p>Thema in der Sidebar anlegen oder auswählen.</p>
|
|
</div>
|
|
<div
|
|
v-if="elementsOpen && selectedTopic"
|
|
class="elements-backdrop"
|
|
@click="elementsOpen = false"
|
|
></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: 100dvh;
|
|
overflow: hidden;
|
|
position: relative;
|
|
}
|
|
|
|
.hover-zone {
|
|
position: fixed;
|
|
left: 0;
|
|
top: 0;
|
|
width: 50px;
|
|
height: 100dvh;
|
|
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: 100dvh;
|
|
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;
|
|
}
|
|
|
|
/* Nur sichtbar, wenn die Elemente-Sidebar mobil als Overlay liegt.
|
|
Tipp daneben schließt sie. */
|
|
.elements-backdrop {
|
|
display: none;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.elements-backdrop {
|
|
display: block;
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 29;
|
|
background: var(--shadow);
|
|
}
|
|
}
|
|
</style>
|