update
This commit is contained in:
574
frontend/src/components/TopicSidebar.vue
Normal file
574
frontend/src/components/TopicSidebar.vue
Normal file
@@ -0,0 +1,574 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
topics: { type: Array, required: true },
|
||||
projects: { type: Array, default: () => [] },
|
||||
selectedTopic: { type: String, default: null },
|
||||
doneByFormat: { type: Object, default: () => ({}) },
|
||||
latestByFormat: { type: Object, default: () => ({}) },
|
||||
allGuides: { type: Array, default: () => [] },
|
||||
pinned: { type: Boolean, default: true },
|
||||
dark: { type: Boolean, default: false },
|
||||
provider: { type: String, default: 'claude' },
|
||||
providers: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'create', 'formatClick', 'deleteTopic', 'deleteProject', 'cancelGuide', 'deleteGuide', 'preview', 'togglePin', 'sidebarLeave', 'toggleDark', 'setProvider'])
|
||||
|
||||
function providerAvailable(id) {
|
||||
const p = props.providers.find((x) => x.id === id)
|
||||
return p ? p.available : true
|
||||
}
|
||||
|
||||
const PROVIDER_LABELS = { claude: 'Claude', minimax: 'MiniMax' }
|
||||
|
||||
const formats = [
|
||||
{ key: 'OnePager', label: 'OnePager' },
|
||||
{ key: 'MiniGuide', label: 'MiniGuide' },
|
||||
{ key: 'Guide', label: 'Guide' },
|
||||
]
|
||||
|
||||
const activeGenerations = computed(() => {
|
||||
return props.allGuides
|
||||
.filter((g) => g.status === 'generating' || g.status === 'queued')
|
||||
.map((g) => `${g.topic} – ${g.format}: ${g.progress || 'Wartend…'}`)
|
||||
})
|
||||
|
||||
function guideStatus(format) {
|
||||
if (props.doneByFormat[format]) return 'done'
|
||||
const latest = props.latestByFormat[format]
|
||||
if (!latest) return 'none'
|
||||
if (latest.status === 'error') return 'none'
|
||||
return latest.status
|
||||
}
|
||||
|
||||
function errorMsg(format) {
|
||||
const latest = props.latestByFormat[format]
|
||||
if (latest?.status === 'error') return latest.error_msg || 'Fehler bei der Generierung'
|
||||
return ''
|
||||
}
|
||||
|
||||
function handleFormatClick(format) {
|
||||
const guide = props.doneByFormat[format]
|
||||
if (guide) {
|
||||
emit('preview', guide)
|
||||
}
|
||||
}
|
||||
|
||||
const activeInput = ref(null)
|
||||
const inputText = ref('')
|
||||
|
||||
function toggleInput(format) {
|
||||
if (activeInput.value === format) {
|
||||
activeInput.value = null
|
||||
inputText.value = ''
|
||||
} else {
|
||||
activeInput.value = format
|
||||
inputText.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function handlePlay(format) {
|
||||
const text = activeInput.value === format ? inputText.value.trim() : ''
|
||||
emit('formatClick', { format, instructions: text })
|
||||
activeInput.value = null
|
||||
inputText.value = ''
|
||||
}
|
||||
|
||||
function dismissError(format) {
|
||||
const latest = props.latestByFormat[format]
|
||||
if (latest?.status === 'error') {
|
||||
emit('deleteGuide', latest.id)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(format) {
|
||||
const guide = props.latestByFormat[format]
|
||||
if (!guide) return
|
||||
if (guide.status === 'generating' || guide.status === 'queued') {
|
||||
if (!confirm('Generierung abbrechen?')) return
|
||||
emit('cancelGuide', guide.id)
|
||||
} else {
|
||||
if (!confirm('Guide löschen?')) return
|
||||
emit('deleteGuide', guide.id)
|
||||
}
|
||||
}
|
||||
|
||||
const newTopic = ref('')
|
||||
|
||||
function submit() {
|
||||
const t = newTopic.value.trim()
|
||||
if (!t) return
|
||||
emit('create', t)
|
||||
newTopic.value = ''
|
||||
}
|
||||
|
||||
function confirmDeleteTopic(topic) {
|
||||
if (!confirm(`Thema "${topic}" und alle zugehörigen Guides löschen?`)) return
|
||||
emit('deleteTopic', topic)
|
||||
}
|
||||
|
||||
function confirmDeleteProject(name) {
|
||||
if (!confirm(`Projekt "${name}" entfernen?\n\nAchtung: Der Quellordner ./projects/${name} wird gelöscht.`)) return
|
||||
emit('deleteProject', name)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="sidebar" @mouseleave="emit('sidebarLeave')">
|
||||
<div class="new-topic">
|
||||
<button
|
||||
class="pin-btn"
|
||||
:title="pinned ? 'Sidebar ausblenden' : 'Sidebar fixieren'"
|
||||
@click="emit('togglePin')"
|
||||
>{{ pinned ? '⇤' : '⇥' }}</button>
|
||||
<button
|
||||
class="theme-btn"
|
||||
:title="dark ? 'Hellmodus' : 'Dunkelmodus'"
|
||||
@click="emit('toggleDark')"
|
||||
>{{ dark ? '☀' : '🌙' }}</button>
|
||||
<input
|
||||
v-model="newTopic"
|
||||
placeholder="Neues Thema…"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
<button @click="submit" :disabled="!newTopic.trim()">+</button>
|
||||
</div>
|
||||
<div class="provider-toggle" v-if="providers.length">
|
||||
<button
|
||||
v-for="p in providers"
|
||||
:key="p.id"
|
||||
:class="{ active: p.id === provider }"
|
||||
:disabled="!p.available"
|
||||
:title="p.available ? '' : 'Nicht konfiguriert (CLI/Key fehlt)'"
|
||||
@click="emit('setProvider', p.id)"
|
||||
>{{ PROVIDER_LABELS[p.id] || p.id }}</button>
|
||||
</div>
|
||||
<div class="format-section" v-if="selectedTopic">
|
||||
<div class="progress-info" v-if="activeGenerations.length">
|
||||
<div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div>
|
||||
</div>
|
||||
<div v-for="f in formats" :key="f.key">
|
||||
<div :class="['format-row', 'fmt-' + guideStatus(f.key)]">
|
||||
<button class="format-name" @click="handleFormatClick(f.key)">
|
||||
<span class="format-label">{{ f.label }}</span>
|
||||
<span
|
||||
v-if="guideStatus(f.key) !== 'none'"
|
||||
class="format-x"
|
||||
@click.stop="handleDelete(f.key)"
|
||||
:title="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued' ? 'Abbrechen' : 'Löschen'"
|
||||
>×</span>
|
||||
</button>
|
||||
<div class="format-actions">
|
||||
<template v-if="guideStatus(f.key) !== 'generating' && guideStatus(f.key) !== 'queued'">
|
||||
<button class="action-btn play" title="Generieren" @click="handlePlay(f.key)">▶</button>
|
||||
<button
|
||||
class="action-btn pencil"
|
||||
:class="{ active: activeInput === f.key }"
|
||||
title="Anweisungen"
|
||||
@click="toggleInput(f.key)"
|
||||
>✎</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="errorMsg(f.key)" class="format-error">
|
||||
<span class="format-error-text">{{ errorMsg(f.key) }}</span>
|
||||
<button class="format-error-x" title="Fehler entfernen" @click="dismissError(f.key)">×</button>
|
||||
</div>
|
||||
<div v-if="activeInput === f.key" class="format-input">
|
||||
<input
|
||||
v-model="inputText"
|
||||
placeholder="Anweisungen (optional)…"
|
||||
@keyup.enter="handlePlay(f.key)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="topic-list">
|
||||
<li
|
||||
v-for="t in topics"
|
||||
:key="t"
|
||||
:class="{ active: t === selectedTopic }"
|
||||
@click="emit('select', t)"
|
||||
>
|
||||
<span>{{ t }}</span>
|
||||
<button class="delete-topic" @click.stop="confirmDeleteTopic(t)" title="Löschen">×</button>
|
||||
</li>
|
||||
<template v-if="projects.length">
|
||||
<li class="projects-divider">Projekte</li>
|
||||
<li
|
||||
v-for="p in projects"
|
||||
:key="'project-' + p"
|
||||
:class="{ active: p === selectedTopic, 'project-item': true }"
|
||||
@click="emit('select', p)"
|
||||
>
|
||||
<span>{{ p }}</span>
|
||||
<button class="delete-topic" @click.stop="confirmDeleteProject(p)" title="Projekt entfernen">×</button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
width: 300px;
|
||||
min-width: 300px;
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.new-topic {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.new-topic input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.new-topic input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.new-topic button {
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.new-topic button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.new-topic .pin-btn {
|
||||
background: var(--bg);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-strong);
|
||||
font-weight: 600;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.new-topic .pin-btn:hover {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-hover);
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
|
||||
.new-topic .theme-btn {
|
||||
background: var(--bg);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-strong);
|
||||
font-weight: 600;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.new-topic .theme-btn:hover {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-hover);
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
|
||||
.provider-toggle {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
padding: 0.5rem 0.75rem 0;
|
||||
}
|
||||
|
||||
.provider-toggle button {
|
||||
flex: 1;
|
||||
padding: 5px 8px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--bg);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.provider-toggle button:first-child {
|
||||
border-radius: 6px 0 0 6px;
|
||||
}
|
||||
|
||||
.provider-toggle button:last-child {
|
||||
border-radius: 0 6px 6px 0;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.provider-toggle button.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.provider-toggle button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.topic-list {
|
||||
list-style: none;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.topic-list li {
|
||||
padding: 0.6rem 1rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
transition: background 0.15s;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.topic-list li:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.topic-list li.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-hover);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.delete-topic {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--danger);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.topic-list li:hover .delete-topic {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.projects-divider {
|
||||
cursor: default;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-faint);
|
||||
font-weight: 700;
|
||||
padding: 0.6rem 1rem 0.3rem;
|
||||
margin-top: 0.4rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.projects-divider:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.topic-list li.project-item span::before {
|
||||
content: '📁 ';
|
||||
}
|
||||
|
||||
/* Format section */
|
||||
.format-section {
|
||||
flex-shrink: 0;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--warning);
|
||||
background: var(--warning-soft);
|
||||
margin-bottom: 0.25rem;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.format-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.75rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.format-row:hover {
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.format-name {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font-size: 0.85rem;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: default;
|
||||
color: var(--text-faint);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.format-x {
|
||||
display: none;
|
||||
color: var(--danger);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.format-name:hover .format-x {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.fmt-done .format-name {
|
||||
color: var(--success);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: var(--success-soft);
|
||||
border: 1px solid var(--success-border);
|
||||
}
|
||||
|
||||
.fmt-done .format-name:hover {
|
||||
background: var(--success-soft-hover);
|
||||
}
|
||||
|
||||
.fmt-generating .format-name,
|
||||
.fmt-queued .format-name {
|
||||
color: var(--warning);
|
||||
background: var(--warning-soft);
|
||||
border: 1px solid var(--warning-border);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.format-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: 2px 0.75rem 6px calc(0.75rem + 8px);
|
||||
font-size: 0.72rem;
|
||||
color: var(--danger);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.format-error-text {
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.format-error-x {
|
||||
flex: 0 0 auto;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--danger);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.format-error-x:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.format-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.action-btn.play {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.action-btn.play:hover {
|
||||
background: var(--success-soft);
|
||||
border-color: var(--success-border);
|
||||
}
|
||||
|
||||
.action-btn.pencil {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.action-btn.pencil:hover,
|
||||
.action-btn.pencil.active {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
|
||||
.format-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 0.75rem 8px;
|
||||
}
|
||||
|
||||
.format-input input {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.format-input input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-btn:disabled:hover {
|
||||
background: none;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.65; }
|
||||
}
|
||||
|
||||
</style>
|
||||
Reference in New Issue
Block a user