This commit is contained in:
Team3
2026-06-06 00:14:43 +02:00
parent 3ed5f7c3e5
commit a8fbf83059
39 changed files with 7347 additions and 472 deletions

View File

@@ -1,13 +1,42 @@
<script setup>
import { ref, onMounted } from 'vue'
import { fetchHealth } from './api.js'
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { fetchGuides, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchProjects, deleteProject as apiDeleteProject, fetchProviders } from './api.js'
import TopicSidebar from './components/TopicSidebar.vue'
import TopicDetail from './components/TopicDetail.vue'
const backendOk = ref(false)
const guides = ref([])
const projects = ref([])
const manualTopics = 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 provider = ref(localStorage.getItem('provider') || 'claude')
const providers = ref([])
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)
@@ -21,49 +50,286 @@ function toggleDark() {
applyTheme()
onMounted(async () => {
try {
const res = await fetchHealth()
backendOk.value = res.ok === true
} catch (e) {
console.error('Backend nicht erreichbar:', e)
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)
const topicDates = {}
for (const g of guides.value) {
if (isProject.has(g.topic)) continue
if (!topicDates[g.topic] || g.created_at > topicDates[g.topic]) {
topicDates[g.topic] = g.created_at
}
}
for (const t of manualTopics.value) {
if (isProject.has(t)) continue
if (!topicDates[t]) topicDates[t] = new Date().toISOString()
}
return Object.keys(topicDates).sort((a, b) => topicDates[b].localeCompare(topicDates[a]))
})
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 loadGuides() {
try {
guides.value = await fetchGuides()
} catch (e) {
console.error('Fehler beim Laden:', 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']
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
nextTick(autoPreview)
}
function createTopic(topic) {
if (!manualTopics.value.includes(topic)) {
manualTopics.value.push(topic)
}
selectedTopic.value = topic
previewGuide.value = null
}
async function handleFormatClick({ format, instructions }) {
if (!selectedTopic.value) 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) {
await deleteGuide(guideId)
if (previewGuide.value?.id === guideId) {
previewGuide.value = null
}
await loadGuides()
}
function startPolling() {
stopPolling()
pollTimer = setInterval(async () => {
await loadGuides()
if (!hasActiveGuides.value) 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)
}
manualTopics.value = manualTopics.value.filter((t) => t !== topic)
if (selectedTopic.value === topic) {
selectedTopic.value = null
previewGuide.value = null
}
await loadGuides()
}
function onVisibility() {
if (document.hidden) {
stopPolling()
} else {
loadGuides()
if (hasActiveGuides.value) startPolling()
}
}
onMounted(async () => {
await Promise.all([loadGuides(), loadProjects(), loadProviders()])
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">
<header class="topbar">
<h1>Creator</h1>
<button class="theme-toggle" @click="toggleDark">{{ darkMode ? '' : '' }}</button>
</header>
<main class="main">
<p>Backend: <span :class="backendOk ? 'ok' : 'err'">{{ backendOk ? 'verbunden' : 'nicht erreichbar' }}</span></p>
</main>
<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"
:doneByFormat="doneByFormat"
:latestByFormat="latestByFormat"
:allGuides="guides"
:pinned="sidebarPinned"
:dark="darkMode"
:provider="provider"
:providers="providers"
@setProvider="setProvider"
@toggleDark="toggleDark"
@select="selectTopic"
@create="createTopic"
@formatClick="handleFormatClick"
@deleteTopic="handleDeleteTopic"
@deleteProject="handleDeleteProject"
@cancelGuide="handleCancel"
@deleteGuide="handleDeleteGuide"
@preview="handlePreview"
@togglePin="toggleSidebarPin"
@sidebarLeave="onSidebarLeave"
/>
<TopicDetail
v-if="selectedTopic"
:previewGuide="previewGuide"
:dark="darkMode"
:provider="provider"
/>
<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);
}
* {
@@ -78,50 +344,66 @@ body {
color: var(--text);
}
input,
textarea {
background: var(--panel);
color: var(--text);
}
input::placeholder,
textarea::placeholder {
color: var(--text-faint);
}
.layout {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
position: relative;
}
.topbar {
.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: space-between;
padding: 0.75rem 1.25rem;
background: var(--panel);
border-bottom: 1px solid var(--border);
h1 {
font-size: 1.1rem;
}
.theme-toggle {
background: none;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
cursor: pointer;
font-size: 1rem;
padding: 0.25rem 0.6rem;
&:hover {
border-color: var(--accent);
}
}
}
.main {
flex: 1;
padding: 1.25rem;
justify-content: center;
color: var(--text-muted);
.ok {
color: var(--success);
}
.err {
color: var(--danger);
}
font-size: 1rem;
}
</style>