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

857 lines
23 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'
import { useConfirm } from '../composables/useConfirm.js'
const props = defineProps({
topics: { type: Array, required: true },
projects: { type: Array, default: () => [] },
selectedTopic: { type: String, default: null },
stats: { type: Object, default: null },
fortschritt: { type: Object, default: () => ({}) },
locks: { type: Object, default: () => ({}) },
uiError: { type: String, default: null },
doneByFormat: { type: Object, default: () => ({}) },
latestByFormat: { type: Object, default: () => ({}) },
allGuides: { type: Array, default: () => [] },
dismissedErrors: { type: Object, default: () => new Set() },
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', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'deleteProject', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openElements', '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', lokal: 'Lokal' }
// Tracker oben in der Navigation: Themen gesamt, pro Format erstellt/absolviert
const trackerItems = computed(() => {
if (!props.stats) return []
const f = props.stats.formate || {}
const fmt = (k) => `${f[k]?.absolviert ?? 0}/${f[k]?.erstellt ?? 0}`
return [
{ label: 'Themen', value: String(props.stats.themen ?? 0), title: 'Themen inkl. Projekte' },
{ label: 'MiniGuides', value: fmt('MiniGuide'), title: 'absolviert/erstellt' },
{ label: 'Guides', value: fmt('Guide'), title: 'absolviert/erstellt' },
{ label: 'FullGuides', value: fmt('FullGuide'), title: 'absolviert/erstellt' },
]
})
const formats = [
{ key: 'OnePager', label: 'OnePager' },
{ key: 'MiniGuide', label: 'MiniGuide' },
{ key: 'Guide', label: 'Guide' },
{ key: 'FullGuide', label: 'FullGuide' },
]
const bausteineState = computed(() => {
if (props.bausteine.generating) return 'generating'
return props.bausteine.ready ? 'done' : 'none'
})
// Nur FREMDE Themen — das gewählte Thema zeigt seinen Fortschritt inline an der Zeile
const activeGenerations = computed(() => {
const bausteinLines = props.activeBausteine
.filter((b) => b.topic !== props.selectedTopic)
.map((b) => `${b.topic} Bausteine: ${b.progress || 'Wartend…'}`)
const guideLines = props.allGuides
.filter((g) => (g.status === 'generating' || g.status === 'queued') && g.topic !== props.selectedTopic)
.map((g) => `${g.topic} ${g.format}: ${g.progress || 'Wartend…'}`)
return [...bausteinLines, ...guideLines]
})
const { pending: pendingConfirm, armOrRun } = useConfirm()
function confirmCancelBausteine() {
armOrRun('bausteine', () => emit('cancelBausteine'))
}
function confirmResetBausteine() {
armOrRun('bausteine', () => emit('resetBausteine'))
}
function handleBausteinePlay() {
if (bausteineState.value === 'generating') return
emit('bausteineClick', { instructions: '' })
}
function guideStatus(format) {
// Laufende Generierung hat Vorrang — sonst maskiert ein älterer fertiger
// Guide den Lauf und ▶ würde Duplikate starten.
const latest = props.latestByFormat[format]
if (latest && (latest.status === 'generating' || latest.status === 'queued')) return latest.status
if (props.doneByFormat[format]) return 'done'
if (!latest || latest.status === 'error') return 'none'
return latest.status
}
// Schritt-Kugeln der Guide-Pipelines
const GUIDE_STEPS = ['Auswahl', 'Gliederung', 'Schreiben', 'Lese-Prüfung']
const ONEPAGER_STEPS = ['Recherche', 'Bauen', 'Prüfung']
// Kugeln werden wie bei den Bausteinen immer angezeigt:
// fertig = alle grün, laufend = live, abgebrochen = Teilfortschritt, sonst grau
function guideSteps(format) {
const labels = format === 'OnePager' ? ONEPAGER_STEPS : GUIDE_STEPS
const st = guideStatus(format)
if (st === 'generating' || st === 'queued') {
// Clamp: alte DB-Läufe können step-Werte oberhalb der neuen Listen haben
const step = Math.min(props.latestByFormat[format]?.step ?? -1, labels.length - 1)
return labels.map((label, i) => ({
label,
state: i < step ? 'done' : i === step ? 'active' : 'pending',
}))
}
if (props.doneByFormat[format]) {
return labels.map((label) => ({ label, state: 'done' }))
}
if (abgebrochen(format)) {
const step = Math.min(props.latestByFormat[format]?.step ?? 0, labels.length)
return labels.map((label, i) => ({ label, state: i < step ? 'done' : 'pending' }))
}
return labels.map((label) => ({ label, state: 'pending' }))
}
function errorMsg(format) {
const latest = props.latestByFormat[format]
if (latest?.status !== 'error' || props.dismissedErrors.has(latest.id)) return ''
if (abgebrochen(format)) return '' // kein roter Fehler — das Pausiert-Badge zeigt den Zustand
return latest.error_msg || 'Fehler bei der Generierung'
}
// Abgebrochener Lauf = Teilfortschritt vorhanden: ▶ setzt fort, ✕ löscht den Fortschritt
function abgebrochen(format) {
const latest = props.latestByFormat[format]
return latest?.status === 'error' && (latest.error_msg || '').startsWith('Abgebrochen')
}
function handleFormatClick(format) {
const guide = props.doneByFormat[format]
if (guide) {
emit('preview', guide)
}
}
// Sperr-Gründe kommen vom Backend (GET /guides/locks) — die Regeln existieren
// nur noch dort. Solange locks noch nicht geladen sind: Button frei, das
// Backend weist ungültige Starts ohnehin ab (sichtbar über uiError).
function playLock(format) {
return props.locks?.[format] ?? null
}
function handlePlay(format) {
if (playLock(format)) return
emit('formatClick', { format, instructions: '' })
}
// Flash-Message-Verhalten: × blendet nur aus, nichts wird gelöscht
function dismissError(format) {
const latest = props.latestByFormat[format]
if (latest?.status === 'error') emit('dismissError', latest.id)
}
function handleDelete(format) {
if (!props.latestByFormat[format]) return
armOrRun('fmt-' + format, () => {
// Alle laufenden Generierungen des Formats abbrechen (deckt auch Duplikate ab)
const running = props.allGuides.filter(
(g) => g.topic === props.selectedTopic && g.format === format
&& (g.status === 'generating' || g.status === 'queued'),
)
if (running.length) {
for (const g of running) emit('cancelGuide', g.id)
} else if (abgebrochen(format)) {
// Pausierter Lauf: Teilfortschritt samt Schritt-Dateien löschen (Reset)
emit('deleteGuide', props.latestByFormat[format].id, true)
} else {
emit('deleteGuide', props.latestByFormat[format].id)
}
})
}
const newTopic = ref('')
function submit() {
const t = newTopic.value.trim()
if (!t) return
emit('create', t)
newTopic.value = ''
}
function confirmDeleteTopic(topic) {
armOrRun('topic-' + topic, () => emit('deleteTopic', topic))
}
function confirmDeleteProject(name) {
armOrRun('project-' + name, () => emit('deleteProject', name))
}
</script>
<template>
<aside class="sidebar" @mouseleave="emit('sidebarLeave')">
<div class="stats-bar" v-if="trackerItems.length">
<div class="stat" v-for="item in trackerItems" :key="item.label" :title="item.title">
<span class="stat-value">{{ item.value }}</span>
<span class="stat-label">{{ item.label }}</span>
</div>
</div>
<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="format-error ui-error" v-if="uiError">
<span class="format-error-text">{{ uiError }}</span>
<button class="format-error-x" title="Ausblenden" @click="emit('dismissUiError')">×</button>
</div>
<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 ord-bausteine"
:class="{ 'is-active': bausteineState === 'generating' || bausteine.partial }"
>
<div class="format-name bausteine-name">
<span class="format-label">Bausteine</span>
<span
v-if="bausteine.partial && bausteineState !== 'generating'"
class="resume-badge"
title="Abgebrochen — ▶ setzt fort"
>Pausiert</span>
<span class="step-dots">
<span
v-for="s in (bausteine.steps || [])"
:key="s.label"
class="step-dot"
:class="s.state"
:title="s.state === 'active' ? (bausteine.progress || s.label) : s.label"
></span>
</span>
<span
v-if="bausteineState === 'generating'"
class="format-x"
:class="{ armed: pendingConfirm === 'bausteine' }"
title="Aktuellen Schritt abbrechen (Fortschritt bleibt)"
@click.stop="confirmCancelBausteine"
>{{ pendingConfirm === 'bausteine' ? 'Sicher?' : '×' }}</span>
<span
v-else-if="bausteine.partial"
class="format-x"
:class="{ armed: pendingConfirm === 'bausteine' }"
title="Fortschritt löschen"
@click.stop="confirmResetBausteine"
>{{ pendingConfirm === 'bausteine' ? 'Sicher?' : '×' }}</span>
</div>
<div class="format-actions">
<template v-if="bausteineState !== 'generating'">
<button
class="action-btn play"
:title="bausteine.partial ? 'Fortsetzen' : bausteine.ready ? 'Bausteine neu erstellen' : 'Bausteine erstellen'"
@click="handleBausteinePlay"
></button>
</template>
</div>
</div>
<div v-if="bausteineState === 'generating'" class="format-progress ord-bausteine">
{{ bausteine.progress || 'Wartend' }}
</div>
<div v-if="bausteine.error && !bausteine.error.startsWith('Abgebrochen')" class="format-error ord-bausteine">
<span class="format-error-text">{{ bausteine.error }}</span>
</div>
<!-- OnePager (unabhängig von Bausteinen) steht per CSS-order vor der Bausteine-Zeile -->
<div v-for="f in formats" :key="f.key" :style="{ order: f.key === 'OnePager' ? 1 : 3 }">
<div :class="['format-row', 'fmt-' + guideStatus(f.key), { 'fmt-paused': abgebrochen(f.key) }]">
<button class="format-name" @click="handleFormatClick(f.key)">
<span class="format-label">{{ f.label }}</span>
<span
v-if="abgebrochen(f.key)"
class="resume-badge"
title="Abgebrochen — ▶ setzt fort"
>Pausiert</span>
<span class="step-dots" v-if="guideSteps(f.key).length">
<span
v-for="s in guideSteps(f.key)"
:key="s.label"
class="step-dot"
:class="s.state"
:title="s.state === 'active' ? (latestByFormat[f.key]?.progress || s.label) : s.label"
></span>
</span>
<span
v-if="guideStatus(f.key) !== 'none' || abgebrochen(f.key)"
class="format-x"
:class="{ armed: pendingConfirm === 'fmt-' + f.key }"
@click.stop="handleDelete(f.key)"
:title="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued' ? 'Abbrechen'
: abgebrochen(f.key) ? 'Fortschritt löschen' : 'Löschen'"
>{{ pendingConfirm === 'fmt-' + f.key ? 'Sicher?' : '×' }}</span>
</button>
<div class="format-actions">
<template v-if="guideStatus(f.key) !== 'generating' && guideStatus(f.key) !== 'queued'">
<button
class="action-btn play"
:title="playLock(f.key) || (abgebrochen(f.key) ? 'Fortsetzen' : 'Generieren')"
:disabled="!!playLock(f.key)"
@click="handlePlay(f.key)"
></button>
</template>
</div>
</div>
<div
v-if="guideStatus(f.key) === 'generating' || guideStatus(f.key) === 'queued'"
class="format-progress"
>{{ latestByFormat[f.key]?.progress || 'Wartend…' }}</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="Ausblenden" @click="dismissError(f.key)">×</button>
</div>
</div>
<div class="format-row ord-elemente">
<button class="format-name elements-btn" @click="emit('openElements')">
<span class="format-label">Elemente</span>
</button>
</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"
:class="{ armed: pendingConfirm === 'topic-' + t }"
@click.stop="confirmDeleteTopic(t)"
title="Thema und alle Guides löschen"
>{{ pendingConfirm === 'topic-' + t ? 'Sicher?' : '×' }}</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"
:class="{ armed: pendingConfirm === 'project-' + p }"
@click.stop="confirmDeleteProject(p)"
title="Projekt entfernen (löscht ./projects-Ordner)"
>{{ pendingConfirm === 'project-' + p ? 'Sicher?' : '×' }}</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: 100dvh;
}
.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);
}
.stats-bar {
display: flex;
padding: 0.5rem 0.75rem;
gap: 4px;
border-bottom: 1px solid var(--border);
}
.stat {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
padding: 4px 2px;
background: var(--panel-soft);
border: 1px solid var(--border);
border-radius: 6px;
cursor: default;
}
.stat-value {
font-size: 0.8rem;
font-weight: 700;
color: var(--text);
}
.stat-label {
font-size: 0.58rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-faint);
white-space: nowrap;
}
.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 */
.bausteine-name {
display: flex;
align-items: center;
gap: 8px;
cursor: default;
}
.step-dots {
display: inline-flex;
gap: 5px;
flex: 1;
}
.step-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--border-strong);
flex-shrink: 0;
}
.step-dot.done {
background: var(--success-border);
}
.step-dot.active {
background: var(--warning-border);
animation: dot-pulse 1.2s ease-in-out infinite;
}
@keyframes dot-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.action-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.format-section {
flex-shrink: 0;
max-height: 60vh;
overflow-y: auto;
padding: 0.5rem 0;
/* flex + order: OnePager (order 1) vor Bausteine (order 2) vor den restlichen Formaten (order 3) */
display: flex;
flex-direction: column;
}
.ord-bausteine {
order: 2;
}
.ord-elemente {
order: 4;
}
.elements-btn {
cursor: pointer;
color: var(--text);
}
.elements-btn:hover {
background: var(--panel-soft);
}
.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;
}
/* Abgewiesene Aktion (409/400) — oberhalb aller Format-Zeilen */
.ui-error {
order: 0;
padding: 0.4rem 0.75rem;
background: var(--warning-soft);
}
/* Fortschritts-Text direkt unter der laufenden Format-Zeile */
.format-progress {
padding: 0 0.75rem 5px calc(0.75rem + 8px);
font-size: 0.72rem;
color: var(--warning);
line-height: 1.3;
animation: pulse 1.5s ease-in-out infinite;
}
.resume-badge {
flex: 0 0 auto;
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--warning);
background: var(--warning-soft);
border: 1px solid var(--warning-border);
border-radius: 4px;
padding: 1px 5px;
}
.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;
gap: 8px;
}
.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;
}
/* Laufend/pausiert: × immer zeigen — Hover gibt es auf Touch nicht */
.fmt-generating .format-x,
.fmt-queued .format-x,
.fmt-paused .format-x,
.bausteine-row.is-active .format-x {
display: inline;
}
.format-x.armed,
.format-error-x.armed,
.delete-topic.armed {
display: inline-block;
font-size: 0.7rem;
font-weight: 700;
background: var(--danger);
color: #fff;
border-radius: 4px;
padding: 2px 6px;
}
.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: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>