Files
guides/frontend/src/components/TopicSidebar.vue
2026-06-03 22:05:20 +02:00

666 lines
14 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: () => [] },
isProjectSelected: { type: Boolean, default: false },
selectedTopic: { type: String, default: null },
doneByFormat: { type: Object, default: () => ({}) },
latestByFormat: { type: Object, default: () => ({}) },
allGuides: { type: Array, default: () => [] },
bausteineActive: { type: Boolean, default: false },
pinned: { type: Boolean, default: true },
})
const emit = defineEmits(['select', 'create', 'formatClick', 'deleteTopic', 'deleteProject', 'cancelGuide', 'deleteGuide', 'preview', 'rework', 'showBausteine', 'addBaustein', 'togglePin', 'sidebarLeave', 'openHelp'])
const reindex = ref(false)
const quickBausteinTitle = ref('')
function submitQuickAdd() {
const title = quickBausteinTitle.value.trim()
if (!title) return
emit('addBaustein', title)
quickBausteinTitle.value = ''
}
const formats = [
{ key: 'OnePager', label: 'OnePager' },
{ key: 'Cheatsheet', label: 'Cheatsheet' },
{ key: 'MiniGuide', label: 'MiniGuide' },
{ key: 'Guide', label: 'Guide' },
{ key: 'EndGuide', label: 'EndGuide' },
]
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, reindex: props.isProjectSelected && reindex.value })
activeInput.value = null
inputText.value = ''
reindex.value = false
}
function handleRefresh(format) {
const guide = props.doneByFormat[format]
if (!guide) return
const text = activeInput.value === format ? inputText.value.trim() : ''
if (!text) return
emit('rework', { guideId: guide.id, instructions: text })
activeInput.value = null
inputText.value = ''
}
function handleInputEnter(format) {
const text = inputText.value.trim()
if (props.doneByFormat[format] && text) {
handleRefresh(format)
} else {
handlePlay(format)
}
}
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} und der Cache werden 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="help-btn"
title="Passendes Thema zu deinem Problem finden"
@click="emit('openHelp')"
>?</button>
<input
v-model="newTopic"
placeholder="Neues Thema…"
@keyup.enter="submit"
/>
<button @click="submit" :disabled="!newTopic.trim()">+</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>
<label v-if="isProjectSelected" class="reindex-toggle">
<input type="checkbox" v-model="reindex" />
<span>Projekt neu einlesen</span>
</label>
<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="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)">&times;</button>
</div>
<div v-if="activeInput === f.key" class="format-input">
<input
v-model="inputText"
:placeholder="doneByFormat[f.key] ? 'Was soll überarbeitet werden?' : 'Anweisungen (optional)…'"
@keyup.enter="handleInputEnter(f.key)"
/>
<button
v-if="doneByFormat[f.key]"
class="action-btn refresh"
title="Überarbeiten"
:disabled="!inputText.trim()"
@click="handleRefresh(f.key)"
></button>
</div>
</div>
<div class="bausteine-btn-wrapper">
<button
class="bausteine-btn"
:class="{ active: bausteineActive }"
@click="emit('showBausteine')"
>Bausteine</button>
<div class="quick-add">
<input
v-model="quickBausteinTitle"
placeholder="Neuer Baustein…"
@keyup.enter="submitQuickAdd"
/>
<button @click="submitQuickAdd" :disabled="!quickBausteinTitle.trim()">+</button>
</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: #fff;
border-right: 1px solid #e2e5e9;
display: flex;
flex-direction: column;
height: 100vh;
}
.new-topic {
display: flex;
gap: 4px;
padding: 0.75rem;
border-bottom: 1px solid #e2e5e9;
}
.new-topic input {
flex: 1;
min-width: 0;
padding: 6px 8px;
border: 1px solid #d8dde3;
border-radius: 6px;
font-size: 0.85rem;
outline: none;
}
.new-topic input:focus {
border-color: #6366f1;
}
.new-topic button {
padding: 6px 10px;
border: none;
background: #6366f1;
color: white;
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: #f8f9fb;
color: #4b5563;
border: 1px solid #d8dde3;
font-weight: 600;
padding: 6px 8px;
}
.new-topic .pin-btn:hover {
background: #ede9fe;
color: #4f46e5;
border-color: #a5b4fc;
}
.new-topic .help-btn {
background: #f8f9fb;
color: #4b5563;
border: 1px solid #d8dde3;
font-weight: 700;
padding: 6px 9px;
}
.new-topic .help-btn:hover {
background: #ede9fe;
color: #4f46e5;
border-color: #a5b4fc;
}
.topic-list {
list-style: none;
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0.5rem 0;
border-top: 1px solid #e2e5e9;
}
.topic-list li {
padding: 0.6rem 1rem;
cursor: pointer;
font-size: 0.9rem;
color: #333;
transition: background 0.15s;
display: flex;
justify-content: space-between;
align-items: center;
}
.topic-list li:hover {
background: #f5f3ff;
}
.topic-list li.active {
background: #ede9fe;
color: #4f46e5;
font-weight: 600;
}
.delete-topic {
display: none;
background: none;
border: none;
color: #991b1b;
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: #9ca3af;
font-weight: 700;
padding: 0.6rem 1rem 0.3rem;
margin-top: 0.4rem;
border-top: 1px solid #e2e5e9;
}
.projects-divider:hover {
background: none;
}
.topic-list li.project-item span::before {
content: '📁 ';
}
.reindex-toggle {
display: flex;
align-items: center;
gap: 6px;
padding: 0.4rem 0.75rem;
font-size: 0.8rem;
color: #4b5563;
cursor: pointer;
}
.reindex-toggle input {
cursor: pointer;
}
/* 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: #92400e;
background: #fef3c7;
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: #f5f5f5;
}
.format-name {
flex: 1;
background: none;
border: none;
text-align: left;
font-size: 0.85rem;
padding: 4px 8px;
border-radius: 4px;
cursor: default;
color: #999;
display: flex;
align-items: center;
justify-content: space-between;
}
.format-x {
display: none;
color: #991b1b;
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
padding: 0 2px;
}
.format-name:hover .format-x {
display: inline;
}
.fmt-done .format-name {
color: #065f46;
font-weight: 600;
cursor: pointer;
background: #d1fae5;
border: 1px solid #34d399;
}
.fmt-done .format-name:hover {
background: #a7f3d0;
}
.fmt-generating .format-name,
.fmt-queued .format-name {
color: #92400e;
background: #fef3c7;
border: 1px solid #fbbf24;
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: #991b1b;
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: #991b1b;
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: #059669;
}
.action-btn.play:hover {
background: #d1fae5;
border-color: #34d399;
}
.action-btn.refresh {
color: #059669;
}
.action-btn.refresh:hover {
background: #d1fae5;
border-color: #34d399;
}
.action-btn.pencil {
color: #6366f1;
}
.action-btn.pencil:hover,
.action-btn.pencil.active {
background: #ede9fe;
border-color: #a5b4fc;
}
.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 #d8dde3;
border-radius: 4px;
font-size: 0.8rem;
outline: none;
}
.format-input input:focus {
border-color: #6366f1;
}
.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; }
}
.bausteine-btn-wrapper {
padding: 0.5rem 0.75rem;
border-top: 1px solid #e2e5e9;
}
.bausteine-btn {
width: 100%;
padding: 8px 12px;
border: 1px solid #d8dde3;
border-radius: 6px;
background: #f8f9fb;
color: #4b5563;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.bausteine-btn:hover {
background: #ede9fe;
border-color: #a5b4fc;
color: #4f46e5;
}
.bausteine-btn.active {
background: #6366f1;
border-color: #6366f1;
color: white;
}
.quick-add {
display: flex;
gap: 4px;
margin-top: 0.5rem;
}
.quick-add input {
flex: 1;
padding: 6px 8px;
border: 1px solid #d8dde3;
border-radius: 6px;
font-size: 0.8rem;
outline: none;
}
.quick-add input:focus {
border-color: #6366f1;
}
.quick-add button {
padding: 6px 10px;
border: none;
background: #6366f1;
color: white;
border-radius: 6px;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
}
.quick-add button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>