Files
creator/frontend/src/components/TopicSidebar.vue
2026-06-06 02:26:42 +02:00

641 lines
15 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 } 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: () => [] },
bausteine: { type: Object, default: () => ({ ready: false, generating: false, progress: null, error: null }) },
activeBausteine: { 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', 'bausteineClick', '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' },
{ key: 'FullGuide', label: 'FullGuide' },
]
const BAUSTEINE_KEY = '__bausteine__'
const bausteineState = computed(() => {
if (props.bausteine.generating) return 'generating'
return props.bausteine.ready ? 'done' : 'none'
})
const activeGenerations = computed(() => {
const bausteinLines = props.activeBausteine.map(
(b) => `${b.topic} Bausteine: ${b.progress || 'Wartend…'}`,
)
const guideLines = props.allGuides
.filter((g) => g.status === 'generating' || g.status === 'queued')
.map((g) => `${g.topic} ${g.format}: ${g.progress || 'Wartend…'}`)
return [...bausteinLines, ...guideLines]
})
function handleBausteinePlay() {
if (bausteineState.value === 'generating') return
const text = activeInput.value === BAUSTEINE_KEY ? inputText.value.trim() : ''
emit('bausteineClick', { instructions: text })
activeInput.value = null
inputText.value = ''
}
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 :class="['format-row', 'bausteine-row', 'fmt-' + bausteineState]">
<button class="format-name">
<span class="format-label">Bausteine</span>
</button>
<div class="format-actions">
<template v-if="bausteineState !== 'generating'">
<button
class="action-btn play"
:title="bausteine.ready ? 'Bausteine neu erstellen' : 'Bausteine erstellen'"
@click="handleBausteinePlay"
></button>
<button
class="action-btn pencil"
:class="{ active: activeInput === BAUSTEINE_KEY }"
title="Anweisungen"
@click="toggleInput(BAUSTEINE_KEY)"
></button>
</template>
</div>
</div>
<div v-if="bausteine.error" class="format-error">
<span class="format-error-text">{{ bausteine.error }}</span>
</div>
<div v-if="activeInput === BAUSTEINE_KEY" class="format-input">
<input
v-model="inputText"
placeholder="Anweisungen (optional)…"
@keyup.enter="handleBausteinePlay"
/>
</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'"
>&times;</span>
</button>
<div class="format-actions">
<template v-if="guideStatus(f.key) !== 'generating' && guideStatus(f.key) !== 'queued'">
<button
class="action-btn play"
:title="bausteine.ready ? 'Generieren' : 'Erst Bausteine erstellen'"
:disabled="!bausteine.ready"
@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)">&times;</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">&times;</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">&times;</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-row.bausteine-row {
margin-bottom: 0.5rem;
}
.action-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.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>