update
This commit is contained in:
@@ -3,11 +3,13 @@ import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { fetchGuides, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, reworkGuide as apiRework } from './api.js'
|
||||
import TopicSidebar from './components/TopicSidebar.vue'
|
||||
import TopicDetail from './components/TopicDetail.vue'
|
||||
import BausteineView from './components/BausteineView.vue'
|
||||
|
||||
const guides = ref([])
|
||||
const manualTopics = ref([])
|
||||
const selectedTopic = ref(null)
|
||||
const previewGuide = ref(null)
|
||||
const showBausteine = ref(false)
|
||||
let pollTimer = null
|
||||
|
||||
const topics = computed(() => {
|
||||
@@ -62,6 +64,7 @@ function autoPreview() {
|
||||
function selectTopic(topic) {
|
||||
selectedTopic.value = topic
|
||||
previewGuide.value = null
|
||||
showBausteine.value = false
|
||||
nextTick(autoPreview)
|
||||
}
|
||||
|
||||
@@ -88,6 +91,12 @@ async function handleRework({ guideId, instructions }) {
|
||||
|
||||
function handlePreview(guide) {
|
||||
previewGuide.value = guide
|
||||
showBausteine.value = false
|
||||
}
|
||||
|
||||
function handleShowBausteine() {
|
||||
showBausteine.value = true
|
||||
previewGuide.value = null
|
||||
}
|
||||
|
||||
async function handleDeleteGuide(guideId) {
|
||||
@@ -161,6 +170,7 @@ onUnmounted(() => {
|
||||
:selectedTopic="selectedTopic"
|
||||
:guidesByFormat="guidesByFormat"
|
||||
:allGuides="guides"
|
||||
:bausteineActive="showBausteine"
|
||||
@select="selectTopic"
|
||||
@create="createTopic"
|
||||
@formatClick="handleFormatClick"
|
||||
@@ -169,9 +179,14 @@ onUnmounted(() => {
|
||||
@deleteGuide="handleDeleteGuide"
|
||||
@preview="handlePreview"
|
||||
@rework="handleRework"
|
||||
@showBausteine="handleShowBausteine"
|
||||
/>
|
||||
<BausteineView
|
||||
v-if="selectedTopic && showBausteine"
|
||||
:topic="selectedTopic"
|
||||
/>
|
||||
<TopicDetail
|
||||
v-if="selectedTopic"
|
||||
v-else-if="selectedTopic"
|
||||
:previewGuide="previewGuide"
|
||||
/>
|
||||
<div v-else class="empty-main">
|
||||
|
||||
@@ -43,3 +43,44 @@ export function pdfUrl(id) {
|
||||
export function htmlUrl(id) {
|
||||
return `${BASE}/guides/${id}/html`
|
||||
}
|
||||
|
||||
export async function fetchBausteine(topic) {
|
||||
const res = await fetch(`${BASE}/bausteine?topic=${encodeURIComponent(topic)}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function createBaustein(topic, title) {
|
||||
const res = await fetch(`${BASE}/bausteine`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, title }),
|
||||
})
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function deleteBaustein(id) {
|
||||
await fetch(`${BASE}/bausteine/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function fetchSuggestions(topic) {
|
||||
const res = await fetch(`${BASE}/bausteine/suggestions?topic=${encodeURIComponent(topic)}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function generateSuggestions(topic) {
|
||||
await fetch(`${BASE}/bausteine/suggestions/generate?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function fetchSuggestionsStatus(topic) {
|
||||
const res = await fetch(`${BASE}/bausteine/suggestions/status?topic=${encodeURIComponent(topic)}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function addSuggestion(id) {
|
||||
const res = await fetch(`${BASE}/bausteine/suggestions/${id}/add`, { method: 'POST' })
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function ignoreSuggestion(id) {
|
||||
await fetch(`${BASE}/bausteine/suggestions/${id}/ignore`, { method: 'POST' })
|
||||
}
|
||||
|
||||
460
frontend/src/components/BausteineView.vue
Normal file
460
frontend/src/components/BausteineView.vue
Normal file
@@ -0,0 +1,460 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import {
|
||||
fetchBausteine,
|
||||
createBaustein,
|
||||
deleteBaustein,
|
||||
fetchSuggestions,
|
||||
generateSuggestions,
|
||||
fetchSuggestionsStatus,
|
||||
addSuggestion,
|
||||
ignoreSuggestion,
|
||||
} from '../api.js'
|
||||
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
})
|
||||
|
||||
const bausteine = ref([])
|
||||
const suggestions = ref([])
|
||||
const suggestionsLoading = ref(false)
|
||||
const newTitle = ref('')
|
||||
let pollTimer = null
|
||||
|
||||
const pendingSuggestions = computed(() => suggestions.value.filter((s) => s.status === 'pending'))
|
||||
const ignoredSuggestions = computed(() => suggestions.value.filter((s) => s.status === 'ignored'))
|
||||
|
||||
function parseExamples(example) {
|
||||
if (!example) return []
|
||||
try {
|
||||
const parsed = JSON.parse(example)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
} catch {}
|
||||
return [{ label: '', code: example }]
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
const [b, s] = await Promise.all([fetchBausteine(props.topic), fetchSuggestions(props.topic)])
|
||||
bausteine.value = b
|
||||
suggestions.value = s
|
||||
}
|
||||
|
||||
async function checkAndGenerate() {
|
||||
const status = await fetchSuggestionsStatus(props.topic)
|
||||
if (status.generating) {
|
||||
suggestionsLoading.value = true
|
||||
startPolling()
|
||||
} else if (suggestions.value.length === 0) {
|
||||
suggestionsLoading.value = true
|
||||
await generateSuggestions(props.topic)
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
const title = newTitle.value.trim()
|
||||
if (!title) return
|
||||
newTitle.value = ''
|
||||
const created = await createBaustein(props.topic, title)
|
||||
bausteine.value.push(created)
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
await deleteBaustein(id)
|
||||
bausteine.value = bausteine.value.filter((b) => b.id !== id)
|
||||
}
|
||||
|
||||
async function handleAccept(s) {
|
||||
const created = await addSuggestion(s.id)
|
||||
suggestions.value = suggestions.value.filter((x) => x.id !== s.id)
|
||||
bausteine.value.push(created)
|
||||
}
|
||||
|
||||
async function handleIgnore(s) {
|
||||
await ignoreSuggestion(s.id)
|
||||
const idx = suggestions.value.findIndex((x) => x.id === s.id)
|
||||
if (idx !== -1) suggestions.value[idx].status = 'ignored'
|
||||
}
|
||||
|
||||
async function handleRestore(s) {
|
||||
const created = await addSuggestion(s.id)
|
||||
suggestions.value = suggestions.value.filter((x) => x.id !== s.id)
|
||||
bausteine.value.push(created)
|
||||
}
|
||||
|
||||
async function handleRegenerate() {
|
||||
suggestionsLoading.value = true
|
||||
await generateSuggestions(props.topic)
|
||||
startPolling()
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling()
|
||||
pollTimer = setInterval(async () => {
|
||||
const status = await fetchSuggestionsStatus(props.topic)
|
||||
if (!status.generating) {
|
||||
suggestionsLoading.value = false
|
||||
stopPolling()
|
||||
await loadData()
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
await loadData()
|
||||
await checkAndGenerate()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.topic,
|
||||
() => {
|
||||
stopPolling()
|
||||
suggestionsLoading.value = false
|
||||
init()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(init)
|
||||
onUnmounted(stopPolling)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bausteine-view">
|
||||
<div class="bausteine-grid">
|
||||
<div class="card new-card">
|
||||
<input
|
||||
v-model="newTitle"
|
||||
placeholder="Neuer Baustein…"
|
||||
@keyup.enter="handleAdd"
|
||||
/>
|
||||
<button @click="handleAdd" :disabled="!newTitle.trim()">+</button>
|
||||
</div>
|
||||
|
||||
<div v-for="b in bausteine" :key="b.id" class="card">
|
||||
<div class="card-header">
|
||||
<h3>{{ b.title }}</h3>
|
||||
<button class="card-delete" @click="handleDelete(b.id)">×</button>
|
||||
</div>
|
||||
<p v-if="b.description" class="desc">{{ b.description }}</p>
|
||||
<p v-if="b.purpose" class="purpose">{{ b.purpose }}</p>
|
||||
<div v-if="b.example" class="examples">
|
||||
<div v-for="(ex, i) in parseExamples(b.example)" :key="i" class="code-block">
|
||||
<span v-if="ex.label" class="code-label">{{ ex.label }}</span>
|
||||
<pre>{{ ex.code }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!b.description && !b.purpose" class="loading-text">Wird generiert…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr v-if="pendingSuggestions.length || suggestionsLoading" class="divider" />
|
||||
|
||||
<div v-if="suggestionsLoading" class="loading-indicator">Generiere Vorschläge…</div>
|
||||
|
||||
<div v-if="pendingSuggestions.length" class="suggestions-section">
|
||||
<div class="section-header">
|
||||
<h3>Vorschläge</h3>
|
||||
<button class="regenerate-btn" @click="handleRegenerate" :disabled="suggestionsLoading">Neu generieren</button>
|
||||
</div>
|
||||
<div class="suggestions-grid">
|
||||
<div v-for="s in pendingSuggestions" :key="s.id" class="card suggestion-card">
|
||||
<h3>{{ s.title }}</h3>
|
||||
<p v-if="s.description" class="desc">{{ s.description }}</p>
|
||||
<p v-if="s.purpose" class="purpose">{{ s.purpose }}</p>
|
||||
<div v-if="s.example" class="examples">
|
||||
<div v-for="(ex, i) in parseExamples(s.example)" :key="i" class="code-block">
|
||||
<span v-if="ex.label" class="code-label">{{ ex.label }}</span>
|
||||
<pre>{{ ex.code }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="suggestion-actions">
|
||||
<button class="btn-add" @click="handleAccept(s)">Hinzufügen</button>
|
||||
<button class="btn-ignore" @click="handleIgnore(s)">Ignorieren</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr v-if="ignoredSuggestions.length" class="divider" />
|
||||
|
||||
<div v-if="ignoredSuggestions.length" class="ignored-section">
|
||||
<h3>Ignoriert</h3>
|
||||
<div v-for="s in ignoredSuggestions" :key="s.id" class="ignored-item">
|
||||
<span>{{ s.title }}</span>
|
||||
<button class="btn-restore" @click="handleRestore(s)">Hinzufügen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bausteine-view {
|
||||
flex: 1;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.bausteine-grid,
|
||||
.suggestions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e5e9;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.new-card {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #f8f9fb;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.new-card input {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #d8dde3;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.new-card input:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.new-card button {
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.new-card button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
font-size: 0.95rem;
|
||||
color: #1a1a1a;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #991b1b;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.card:hover .card-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 0.85rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.purpose {
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.examples {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: #1e2a3a;
|
||||
color: #e6e6e6;
|
||||
font-family: "SF Mono", Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
padding: 12px 14px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.code-block pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.code-label {
|
||||
font-family: -apple-system, sans-serif;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #8be9fd;
|
||||
margin-bottom: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 0.8rem;
|
||||
color: #9ca3af;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.divider {
|
||||
border: none;
|
||||
border-top: 1px solid #e2e5e9;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
font-size: 0.85rem;
|
||||
color: #92400e;
|
||||
background: #fef3c7;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
font-size: 0.9rem;
|
||||
color: #4b5563;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.regenerate-btn {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid #d8dde3;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.regenerate-btn:hover {
|
||||
border-color: #6366f1;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.regenerate-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.suggestion-card {
|
||||
border-color: #c7d2fe;
|
||||
background: #fafafe;
|
||||
}
|
||||
|
||||
.suggestion-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: auto;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid #34d399;
|
||||
border-radius: 4px;
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-add:hover {
|
||||
background: #a7f3d0;
|
||||
}
|
||||
|
||||
.btn-ignore {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid #d8dde3;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-ignore:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.ignored-section h3 {
|
||||
font-size: 0.85rem;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.ignored-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0;
|
||||
font-size: 0.85rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.btn-restore {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid #d8dde3;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #6b7280;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-restore:hover {
|
||||
border-color: #34d399;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.65; }
|
||||
}
|
||||
</style>
|
||||
@@ -6,9 +6,10 @@ const props = defineProps({
|
||||
selectedTopic: { type: String, default: null },
|
||||
guidesByFormat: { type: Object, default: () => ({}) },
|
||||
allGuides: { type: Array, default: () => [] },
|
||||
bausteineActive: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'create', 'formatClick', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'preview', 'rework'])
|
||||
const emit = defineEmits(['select', 'create', 'formatClick', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'preview', 'rework', 'showBausteine'])
|
||||
|
||||
const formats = [
|
||||
{ key: 'OnePager', label: 'OnePager' },
|
||||
@@ -155,6 +156,13 @@ function submit() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bausteine-btn-wrapper">
|
||||
<button
|
||||
class="bausteine-btn"
|
||||
:class="{ active: bausteineActive }"
|
||||
@click="emit('showBausteine')"
|
||||
>Bausteine</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -399,4 +407,34 @@ function submit() {
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user