update
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user