This commit is contained in:
team3
2026-06-07 15:17:50 +02:00
parent 1649a046d2
commit af5c0950ea
16 changed files with 1897 additions and 34 deletions

View File

@@ -3,6 +3,8 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { fetchGuides, fetchTopics, createTopic as apiCreateTopic, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBausteineStatus, fetchActiveBausteine, createBausteine as apiCreateBausteine, cancelBausteine as apiCancelBausteine, deleteBausteine as apiDeleteBausteine, fetchProjects, deleteProject as apiDeleteProject, fetchProviders, fetchStats, fetchTopicFortschritt } from './api.js'
import TopicSidebar from './components/TopicSidebar.vue'
import TopicDetail from './components/TopicDetail.vue'
import ElementsSidebar from './components/ElementsSidebar.vue'
import ElementsOverview from './components/ElementsOverview.vue'
const guides = ref([])
const projects = ref([])
@@ -23,6 +25,11 @@ const provider = ref(localStorage.getItem('provider') || 'claude')
const providers = ref([])
const stats = ref(null)
const fortschritt = ref({})
const elementsOpen = ref(false) // rechte Sidebar
const elementsView = ref(false) // Übersicht im Hauptbereich
const elementsVersion = ref(0) // Erhöhung = Übersicht neu laden
const elementOpenId = ref(null) // Element aus Übersicht in Sidebar öffnen
const elementOpenTick = ref(0)
async function loadStats() {
try {
@@ -182,6 +189,9 @@ function selectTopic(topic) {
selectedTopic.value = topic
previewGuide.value = null
sidebarSticky.value = false
elementsOpen.value = false
elementsView.value = false
elementOpenId.value = null
localStorage.setItem('lastTopic', topic)
loadBausteine()
nextTick(autoPreview)
@@ -243,6 +253,19 @@ async function handleDeleteProject(name) {
function handlePreview(guide) {
previewGuide.value = guide
elementsView.value = false
}
function handleOpenElements() {
if (!selectedTopic.value) return
elementsView.value = true
elementsOpen.value = true
}
function handleOpenElementDetail(el) {
elementOpenId.value = el.id
elementOpenTick.value++
elementsOpen.value = true
}
async function handleDeleteGuide(guideId, slots = false) {
@@ -353,19 +376,36 @@ onUnmounted(() => {
@deleteGuide="handleDeleteGuide"
@dismissError="handleDismissError"
@preview="handlePreview"
@openElements="handleOpenElements"
@togglePin="toggleSidebarPin"
@sidebarLeave="onSidebarLeave"
/>
<ElementsOverview
v-if="selectedTopic && elementsView"
:topic="selectedTopic"
:version="elementsVersion"
@open="handleOpenElementDetail"
/>
<TopicDetail
v-if="selectedTopic"
v-else-if="selectedTopic"
:previewGuide="previewGuide"
:dark="darkMode"
:provider="provider"
@progressChanged="loadStats(); loadBausteine()"
@openElements="elementsOpen = true"
/>
<div v-else class="empty-main">
<p>Thema in der Sidebar anlegen oder auswählen.</p>
</div>
<ElementsSidebar
v-if="elementsOpen && selectedTopic"
:topic="selectedTopic"
:provider="provider"
:openId="elementOpenId"
:openTick="elementOpenTick"
@close="elementsOpen = false"
@changed="elementsVersion++"
/>
</div>
</template>

View File

@@ -118,3 +118,59 @@ export async function chatGuide(id, { section, outline, messages, provider = 'cl
})
return res.json()
}
export async function fetchElements(topic) {
const res = await fetch(`${BASE}/elements?topic=${encodeURIComponent(topic)}`)
return res.json()
}
export async function createElement(topic, hint = '', provider = 'claude') {
const res = await fetch(`${BASE}/elements`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, hint, provider }),
})
return res.json()
}
export async function chatElement(id, messages, provider = 'claude') {
const res = await fetch(`${BASE}/elements/${id}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, provider }),
})
return res.json()
}
export async function deleteElement(id) {
await fetch(`${BASE}/elements/${id}`, { method: 'DELETE' })
}
export async function updateElement(id, fields) {
const res = await fetch(`${BASE}/elements/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fields),
})
return res.json()
}
export async function styleElement(id, provider = 'claude') {
const res = await fetch(`${BASE}/elements/${id}/style`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider }),
})
if (!res.ok) throw new Error(`Stil-Prüfung fehlgeschlagen (${res.status})`)
return res.json()
}
export async function checkElement(id, provider = 'claude') {
const res = await fetch(`${BASE}/elements/${id}/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider }),
})
if (!res.ok) throw new Error(`Prüfung fehlgeschlagen (${res.status})`)
return res.json()
}

View File

@@ -0,0 +1,205 @@
<script setup>
import { ref, watch } from 'vue'
import { fetchElements } from '../api.js'
import { renderMarkdown } from '../markdown.js'
const props = defineProps({
topic: { type: String, required: true },
version: { type: Number, default: 0 }, // Erhöhung = Elemente neu laden
})
const emit = defineEmits(['open'])
const elements = ref([])
watch([() => props.topic, () => props.version], load, { immediate: true })
async function load() {
try {
elements.value = await fetchElements(props.topic)
} catch (e) {
console.error('Fehler beim Laden der Elemente:', e)
}
}
function plain(text) {
return (text || '').replace(/```[a-z]*\n?/g, '').replace(/[`*_#]/g, '')
}
</script>
<template>
<div class="elements-overview">
<div class="overview-scroll">
<div class="overview-content">
<header class="overview-head">
<h1>{{ topic }}</h1>
<span class="overview-format">Elemente</span>
</header>
<p v-if="!elements.length" class="overview-empty">
Noch keine Elemente. Rechts in der Sidebar Stichwort eingeben und + klicken.
</p>
<div class="element-grid">
<article
v-for="el in elements"
:key="el.id"
class="element-card"
@click="emit('open', el)"
>
<h3>{{ plain(el.title) }}</h3>
<div class="markdown" v-html="renderMarkdown(el.description)"></div>
<div v-for="(ex, i) in el.examples" :key="i" class="markdown el-example" v-html="renderMarkdown(ex)"></div>
<div v-if="el.hints.length" class="el-hints-block">
<h4>Hinweise</h4>
<ul>
<li v-for="(h, i) in el.hints" :key="i" class="markdown" v-html="renderMarkdown(h)"></li>
</ul>
</div>
</article>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.elements-overview {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--bg-preview);
}
.overview-scroll {
flex: 1;
overflow-y: auto;
}
.overview-content {
max-width: 1000px;
margin: 0 auto;
padding: 2.5rem 2rem 4rem;
}
.overview-head {
display: flex;
align-items: baseline;
gap: 0.8rem;
margin-bottom: 1.5rem;
}
.overview-head h1 {
margin: 0;
font-size: 2.2rem;
color: var(--text);
}
.overview-format {
font-size: 1rem;
font-weight: 600;
color: var(--text-faint);
}
.overview-empty {
color: var(--text-muted);
}
.element-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 1rem;
align-items: start;
}
.element-card {
background: var(--panel);
border: 1px solid var(--border);
border-top: 3px solid var(--accent);
border-radius: 10px;
padding: 1rem 1.1rem;
cursor: pointer;
transition: box-shadow 0.15s, transform 0.15s;
}
.element-card:hover {
box-shadow: 0 4px 16px var(--shadow);
transform: translateY(-1px);
}
.element-card h3 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: var(--text);
}
.el-example {
margin-top: 0.5rem;
}
.el-hints-block {
margin-top: 0.7rem;
}
.el-hints-block h4 {
margin: 0 0 0.3rem;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
}
.el-hints-block ul {
margin: 0;
padding-left: 1.1rem;
}
.el-hints-block li {
font-size: 0.85rem;
line-height: 1.5;
color: var(--text);
margin-bottom: 0.2rem;
}
/* Markdown im Guide-Stil */
.markdown {
font-size: 0.9rem;
line-height: 1.55;
color: var(--text);
}
.markdown :deep(p) {
margin: 0 0 0.5em;
}
.markdown :deep(p:last-child) {
margin-bottom: 0;
}
.markdown :deep(code) {
background: var(--border);
padding: 1px 4px;
border-radius: 4px;
font-family: "SF Mono", Consolas, monospace;
font-size: 0.85em;
overflow-wrap: anywhere;
}
.markdown :deep(pre) {
background: var(--code-bg, #1e2330);
color: var(--code-fg, #e6e8ee);
padding: 10px 12px;
border-radius: 8px;
/* Umbrechen statt horizontal scrollen — Scrollbar verdeckt sonst die Code-Zeile */
white-space: pre-wrap;
overflow-wrap: anywhere;
margin: 0.4em 0;
}
.markdown :deep(pre code) {
background: none;
padding: 0;
color: inherit;
font-size: 0.85em;
}
</style>

View File

@@ -0,0 +1,986 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { fetchElements, createElement, chatElement, deleteElement, updateElement, checkElement, styleElement } from '../api.js'
import { renderMarkdown } from '../markdown.js'
const props = defineProps({
topic: { type: String, required: true },
provider: { type: String, default: 'claude' },
openId: { type: String, default: null }, // Element-ID, die geöffnet werden soll
openTick: { type: Number, default: 0 }, // Erhöhung = openId (erneut) öffnen
})
const emit = defineEmits(['close', 'changed'])
const elements = ref([])
const query = ref('')
const creating = ref(false)
const selected = ref(null)
watch(() => props.topic, load, { immediate: true })
async function load() {
selected.value = null
query.value = ''
try {
elements.value = await fetchElements(props.topic)
} catch (e) {
console.error('Fehler beim Laden der Elemente:', e)
}
openFromProp()
}
// Aus der Übersicht im Hauptbereich angeklicktes Element öffnen
watch(() => props.openTick, openFromProp)
function openFromProp() {
if (!props.openId) return
const el = elements.value.find((e) => e.id === props.openId)
if (el) select(el)
}
// Markdown-Zeichen für Titel und Listen-Vorschau entfernen
function plain(text) {
return (text || '').replace(/```[a-z]*\n?/g, '').replace(/[`*_#]/g, '')
}
const filtered = computed(() => {
const q = query.value.trim().toLowerCase()
if (!q) return elements.value
return elements.value.filter(
(el) => el.title.toLowerCase().includes(q) || el.description.toLowerCase().includes(q),
)
})
async function add() {
if (creating.value) return
creating.value = true
try {
const el = await createElement(props.topic, query.value.trim(), props.provider)
elements.value.unshift(el)
query.value = ''
emit('changed')
} catch (e) {
console.error('Fehler beim Erstellen des Elements:', e)
} finally {
creating.value = false
}
}
function select(el) {
selected.value = el
messages.value = []
input.value = ''
resetCheck()
nextTick(() => inputEl.value?.focus())
}
function back() {
selected.value = null
messages.value = []
resetCheck()
}
// --- KI-Prüfung auf fehlende Infos (Ergebnisse landen als Inline-Vorschläge) ---
const checking = ref(false)
const statusMsg = ref(null)
function resetCheck() {
checking.value = false
statusMsg.value = null
resetStyle()
}
let checkRun = 0 // laufende Prüfung identifizieren; Abbruch ignoriert ihr Ergebnis
async function runCheck() {
if (!selected.value) return
if (checking.value) { // zweiter Klick = abbrechen
checkRun++
checking.value = false
return
}
const run = ++checkRun
checking.value = true
statusMsg.value = null
try {
const res = await checkElement(selected.value.id, props.provider)
if (run !== checkRun) return // abgebrochen oder neue Prüfung gestartet
const mapped = res.suggestions.map((s) => ({
text: s.text, action: 'hinzufuegen', target: s.target, index: null, content: s.content,
}))
if (mapped.length) styleChanges.value = [...(styleChanges.value || []), ...mapped]
else statusMsg.value = 'Keine wichtigen Lücken gefunden.'
} catch (e) {
if (run !== checkRun) return
console.error('Prüfung fehlgeschlagen:', e)
statusMsg.value = 'Prüfung fehlgeschlagen — bitte erneut versuchen.'
} finally {
if (run === checkRun) checking.value = false
}
}
// --- Stil-Prüfung: KI schlägt Änderungen vor, Nutzer bestätigt ---
const styleChanges = ref(null) // null = noch nicht geprüft
const styling = ref(false)
const applyingStyle = ref(false)
const ACTION_LABELS = { entfernen: 'Entfernen:', anpassen: 'Anpassen:', hinzufuegen: 'Hinzufügen:' }
let styleRun = 0
function resetStyle() {
styleChanges.value = null
styling.value = false
applyingStyle.value = false
}
async function runStyle() {
if (!selected.value) return
if (styling.value) { // zweiter Klick = abbrechen
styleRun++
styling.value = false
return
}
const run = ++styleRun
styling.value = true
statusMsg.value = null
try {
const res = await styleElement(selected.value.id, props.provider)
if (run !== styleRun) return
if (res.changes.length) styleChanges.value = [...(styleChanges.value || []), ...res.changes]
else statusMsg.value = 'Stil passt bereits.'
} catch (e) {
if (run !== styleRun) return
console.error('Stil-Prüfung fehlgeschlagen:', e)
statusMsg.value = 'Stil-Prüfung fehlgeschlagen — bitte erneut versuchen.'
} finally {
if (run === styleRun) styling.value = false
}
}
// Vorschläge am Ziel-Ort anzeigen: anpassen/entfernen beim betroffenen Eintrag …
function styleAt(target, index = null) {
if (!styleChanges.value) return []
return styleChanges.value
.map((c, i) => [i, c])
.filter(([, c]) => c.target === target && c.index === index && c.action !== 'hinzufuegen')
}
// … Ergänzungen am Ende der jeweiligen Sektion
function styleAdds(target) {
if (!styleChanges.value) return []
return styleChanges.value
.map((c, i) => [i, c])
.filter(([, c]) => c.target === target && c.action === 'hinzufuegen')
}
function dismissStyleChange(i) {
styleChanges.value = styleChanges.value.filter((_, j) => j !== i)
}
async function applyStyleChange(i) {
if (applyingStyle.value || !selected.value) return
const c = styleChanges.value[i]
applyingStyle.value = true
try {
const fields = {
title: selected.value.title,
description: selected.value.description,
examples: [...selected.value.examples],
hints: [...selected.value.hints],
}
if (c.action === 'entfernen') fields[c.target].splice(c.index, 1)
else if (c.action === 'hinzufuegen') {
if (c.target === 'title') fields.title = c.content
else if (c.target === 'description') fields.description += '\n\n' + c.content
else fields[c.target].push(c.content)
} else if (c.target === 'title' || c.target === 'description') fields[c.target] = c.content
else fields[c.target][c.index] = c.content
const updated = await updateElement(selected.value.id, fields)
selected.value = updated
const idx = elements.value.findIndex((e) => e.id === updated.id)
if (idx !== -1) elements.value.splice(idx, 1, updated)
// Rest-Vorschläge behalten; Indizes hinter einer Entfernung rücken auf
const rest = styleChanges.value.filter((_, j) => j !== i)
if (c.action === 'entfernen') {
for (const r of rest) {
if (r.target === c.target && r.index !== null && r.index > c.index) r.index--
}
}
styleChanges.value = rest
emit('changed')
} catch (e) {
console.error('Übernehmen fehlgeschlagen:', e)
} finally {
applyingStyle.value = false
}
}
// Inline-Bestätigung wie in TopicSidebar: erster Klick „Sicher?", zweiter löscht
const pendingDelete = ref(null)
let deleteTimer = null
async function confirmDelete(el) {
clearTimeout(deleteTimer)
if (pendingDelete.value === el.id) {
pendingDelete.value = null
await deleteElement(el.id)
elements.value = elements.value.filter((e) => e.id !== el.id)
if (selected.value?.id === el.id) back()
emit('changed')
} else {
pendingDelete.value = el.id
deleteTimer = setTimeout(() => { pendingDelete.value = null }, 3000)
}
}
// --- Chat zum Anpassen des Elements ---
const messages = ref([])
const input = ref('')
const loading = ref(false)
const messagesEl = ref(null)
const inputEl = ref(null)
async function scrollToBottom() {
await nextTick()
if (messagesEl.value) messagesEl.value.scrollTop = messagesEl.value.scrollHeight
}
async function send() {
const text = input.value.trim()
if (!text || loading.value || !selected.value) return
messages.value.push({ role: 'user', content: text })
input.value = ''
loading.value = true
scrollToBottom()
try {
const res = await chatElement(selected.value.id, messages.value, props.provider)
messages.value.push({ role: 'assistant', content: res.reply || '…' })
if (res.element) {
selected.value = res.element
const i = elements.value.findIndex((e) => e.id === res.element.id)
if (i !== -1) elements.value.splice(i, 1, res.element)
emit('changed')
}
} catch {
messages.value.push({ role: 'assistant', content: 'Fehler bei der Anfrage.' })
} finally {
loading.value = false
scrollToBottom()
nextTick(() => inputEl.value?.focus())
}
}
</script>
<template>
<aside class="elements-sidebar">
<header class="el-header">
<button v-if="selected" class="el-back" title="Zur Liste" @click="back"></button>
<span class="el-title">{{ selected ? plain(selected.title) : 'Elemente' }}</span>
<button
v-if="selected" class="el-tool" :class="{ busy: checking }"
:title="checking ? 'Prüfung abbrechen' : 'Auf fehlende Infos prüfen'" @click="runCheck"
>🔍</button>
<button
v-if="selected" class="el-tool" :class="{ busy: styling }"
:title="styling ? 'Prüfung abbrechen' : 'Stil prüfen & anpassen'" @click="runStyle"
></button>
<button class="el-close" title="Schließen" @click="emit('close')">×</button>
</header>
<!-- Listen-Modus -->
<template v-if="!selected">
<div class="el-new">
<input
v-model="query"
placeholder="Suchen oder Stichwort…"
:disabled="creating"
@keyup.enter="add"
/>
<button :disabled="creating" title="Element per KI erstellen" @click="add">+</button>
</div>
<div v-if="creating" class="el-creating">KI erstellt Element</div>
<ul class="el-list">
<li v-for="el in filtered" :key="el.id" @click="select(el)">
<div class="el-item-main">
<span class="el-item-title">{{ plain(el.title) }}</span>
<span class="el-item-desc">{{ plain(el.description) }}</span>
</div>
<button
class="el-delete"
:class="{ armed: pendingDelete === el.id }"
title="Element löschen"
@click.stop="confirmDelete(el)"
>{{ pendingDelete === el.id ? 'Sicher?' : '×' }}</button>
</li>
<li v-if="!filtered.length && !creating" class="el-empty">
{{ elements.length ? 'Keine Treffer.' : 'Noch keine Elemente. Stichwort eingeben und + klicken.' }}
</li>
</ul>
</template>
<!-- Detail-Modus -->
<template v-else>
<div class="el-detail">
<div v-if="selected.description" class="el-desc markdown" v-html="renderMarkdown(selected.description)"></div>
<div
v-for="[ci, c] in [...styleAt('title'), ...styleAt('description'), ...styleAdds('description')]"
:key="'sgd' + ci" class="style-sugg"
>
<div class="style-sugg-text"><strong>{{ ACTION_LABELS[c.action] }}</strong> {{ c.text }}</div>
<div v-if="c.content" class="style-sugg-preview markdown" v-html="renderMarkdown(c.content)"></div>
<div class="style-sugg-actions">
<button class="sugg-ok" :disabled="applyingStyle" @click="applyStyleChange(ci)">Bestätigen</button>
<button class="sugg-no" :disabled="applyingStyle" @click="dismissStyleChange(ci)">Ablehnen</button>
</div>
</div>
<template v-for="(ex, i) in selected.examples" :key="i">
<div class="el-entry markdown" v-html="renderMarkdown(ex)"></div>
<div v-for="[ci, c] in styleAt('examples', i)" :key="'sge' + ci" class="style-sugg">
<div class="style-sugg-text"><strong>{{ ACTION_LABELS[c.action] }}</strong> {{ c.text }}</div>
<div v-if="c.content" class="style-sugg-preview markdown" v-html="renderMarkdown(c.content)"></div>
<div class="style-sugg-actions">
<button class="sugg-ok" :disabled="applyingStyle" @click="applyStyleChange(ci)">Bestätigen</button>
<button class="sugg-no" :disabled="applyingStyle" @click="dismissStyleChange(ci)">Ablehnen</button>
</div>
</div>
</template>
<div v-for="[ci, c] in styleAdds('examples')" :key="'sgea' + ci" class="style-sugg">
<div class="style-sugg-text"><strong>{{ ACTION_LABELS[c.action] }}</strong> {{ c.text }}</div>
<div v-if="c.content" class="style-sugg-preview markdown" v-html="renderMarkdown(c.content)"></div>
<div class="style-sugg-actions">
<button class="sugg-ok" :disabled="applyingStyle" @click="applyStyleChange(ci)">Bestätigen</button>
<button class="sugg-no" :disabled="applyingStyle" @click="dismissStyleChange(ci)">Ablehnen</button>
</div>
</div>
<div v-if="selected.hints.length || styleAdds('hints').length" class="el-hints-block">
<h4>Hinweise</h4>
<ul class="el-hints">
<li v-for="(h, i) in selected.hints" :key="i">
<span class="markdown" v-html="renderMarkdown(h)"></span>
<div v-for="[ci, c] in styleAt('hints', i)" :key="'sgh' + ci" class="style-sugg">
<div class="style-sugg-text"><strong>{{ ACTION_LABELS[c.action] }}</strong> {{ c.text }}</div>
<div v-if="c.content" class="style-sugg-preview markdown" v-html="renderMarkdown(c.content)"></div>
<div class="style-sugg-actions">
<button class="sugg-ok" :disabled="applyingStyle" @click="applyStyleChange(ci)">Bestätigen</button>
<button class="sugg-no" :disabled="applyingStyle" @click="dismissStyleChange(ci)">Ablehnen</button>
</div>
</div>
</li>
</ul>
<div v-for="[ci, c] in styleAdds('hints')" :key="'sgha' + ci" class="style-sugg">
<div class="style-sugg-text"><strong>{{ ACTION_LABELS[c.action] }}</strong> {{ c.text }}</div>
<div v-if="c.content" class="style-sugg-preview markdown" v-html="renderMarkdown(c.content)"></div>
<div class="style-sugg-actions">
<button class="sugg-ok" :disabled="applyingStyle" @click="applyStyleChange(ci)">Bestätigen</button>
<button class="sugg-no" :disabled="applyingStyle" @click="dismissStyleChange(ci)">Ablehnen</button>
</div>
</div>
</div>
<div v-if="checking || styling || statusMsg" class="el-check">
<p v-if="checking" class="check-empty busy-text">Prüft auf fehlende Infos</p>
<p v-if="styling" class="check-empty busy-text">Prüft den Stil</p>
<p v-if="statusMsg && !checking && !styling" class="check-empty">{{ statusMsg }}</p>
</div>
</div>
<div class="el-chat">
<div ref="messagesEl" class="chat-messages">
<p v-if="!messages.length" class="chat-hint">Schreib, was am Element geändert werden soll.</p>
<template v-for="(m, i) in messages" :key="i">
<div :class="['chat-msg', m.role]">{{ m.content }}</div>
</template>
<div v-if="loading" class="chat-msg assistant chat-typing">Passt an</div>
</div>
<div class="chat-input">
<textarea
ref="inputEl"
v-model="input"
placeholder="Element anpassen…"
@keydown.enter.exact.prevent="send"
></textarea>
<button :disabled="!input.trim() || loading" @click="send"></button>
</div>
</div>
</template>
</aside>
</template>
<style scoped>
.elements-sidebar {
width: 320px;
min-width: 320px;
height: 100vh;
display: flex;
flex-direction: column;
background: var(--panel);
border-left: 1px solid var(--border);
/* Über dem Guide-Chat (FAB/Panel: z-index 20) */
position: relative;
z-index: 30;
}
.el-header {
display: flex;
align-items: center;
gap: 6px;
padding: 0.6rem 0.9rem;
border-bottom: 1px solid var(--border);
}
.el-title {
flex: 1;
font-weight: 600;
font-size: 0.9rem;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.el-back,
.el-close {
border: none;
background: none;
color: var(--text-faint);
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
padding: 0 4px;
}
.el-back:hover,
.el-close:hover {
color: var(--text);
}
.el-tool {
border: none;
background: none;
font-size: 0.95rem;
line-height: 1;
cursor: pointer;
padding: 2px 3px;
border-radius: 6px;
filter: grayscale(0.4);
}
.el-tool:hover {
background: var(--panel-soft);
filter: none;
}
.el-tool.busy {
filter: none;
animation: pulse 1.5s ease-in-out infinite;
}
.busy-text {
animation: pulse 1.5s ease-in-out infinite;
}
.el-new {
display: flex;
gap: 6px;
padding: 0.6rem 0.75rem;
}
.el-new input {
flex: 1;
padding: 8px 10px;
border: 1px solid var(--border-strong);
border-radius: 8px;
font-size: 0.85rem;
background: var(--panel);
color: var(--text);
outline: none;
}
.el-new input:focus {
border-color: var(--accent);
}
.el-new button {
width: 38px;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--on-accent);
font-size: 1.1rem;
cursor: pointer;
}
.el-new button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.el-creating {
padding: 0.4rem 0.75rem;
font-size: 0.78rem;
color: var(--warning);
background: var(--warning-soft);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
50% { opacity: 0.35; }
}
.el-list {
flex: 1;
overflow-y: auto;
list-style: none;
margin: 0;
padding: 0.25rem 0;
}
.el-list li {
display: flex;
align-items: center;
gap: 6px;
padding: 0.5rem 0.75rem;
cursor: pointer;
transition: background 0.15s;
}
.el-list li:hover {
background: var(--panel-soft);
}
.el-item-main {
flex: 1;
min-width: 0;
}
.el-item-title {
display: block;
font-size: 0.85rem;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.el-item-desc {
display: block;
font-size: 0.75rem;
color: var(--text-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.el-delete {
border: none;
background: none;
color: var(--danger);
font-size: 1rem;
line-height: 1;
cursor: pointer;
padding: 0 2px;
visibility: hidden;
}
.el-list li:hover .el-delete {
visibility: visible;
}
.el-delete.armed {
visibility: visible;
font-size: 0.7rem;
font-weight: 700;
background: var(--danger);
color: #fff;
border-radius: 4px;
padding: 2px 6px;
}
.el-empty {
cursor: default !important;
color: var(--text-faint);
font-size: 0.8rem;
}
.el-empty:hover {
background: none !important;
}
.el-detail {
flex: 1;
overflow-y: auto;
padding: 0.9rem;
}
.el-desc {
margin: 0 0 0.9rem;
font-size: 0.85rem;
line-height: 1.6;
color: var(--text);
}
.el-hints-block {
margin-top: 0.9rem;
}
.el-hints-block h4 {
margin: 0 0 0.35rem;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
}
.el-entry {
font-size: 0.82rem;
line-height: 1.5;
color: var(--text);
margin-bottom: 0.4rem;
}
.el-entry:last-child {
margin-bottom: 0;
}
.el-hints {
margin: 0;
padding-left: 1.1rem;
}
.el-hints li {
font-size: 0.82rem;
line-height: 1.5;
color: var(--text);
margin-bottom: 0.25rem;
}
/* Hinweis-Text inline neben dem Bullet halten (p ist sonst block) */
.el-hints li > .markdown {
display: inline;
}
.el-hints li > .markdown :deep(p) {
display: inline;
margin: 0;
}
/* --- Markdown im Guide-Stil (Muster: TopicDetail) --- */
.markdown :deep(p) {
margin: 0 0 0.5em;
}
.markdown :deep(p:last-child) {
margin-bottom: 0;
}
.markdown :deep(ul),
.markdown :deep(ol) {
margin: 0.3em 0;
padding-left: 1.2em;
}
.markdown :deep(code) {
background: var(--border);
padding: 1px 4px;
border-radius: 4px;
font-family: "SF Mono", Consolas, monospace;
font-size: 0.85em;
overflow-wrap: anywhere;
}
.markdown :deep(pre) {
background: var(--code-bg, #1e2330);
color: var(--code-fg, #e6e8ee);
padding: 8px 10px;
border-radius: 8px;
/* Schmale Sidebar: Code umbrechen statt horizontal scrollen */
white-space: pre-wrap;
overflow-wrap: anywhere;
margin: 0.4em 0;
}
.markdown :deep(pre code) {
background: none;
padding: 0;
color: inherit;
font-size: 0.85em;
}
/* --- Stil-Vorschläge inline am Ziel --- */
.style-sugg {
margin: 0.3rem 0 0.6rem;
padding: 0.5rem 0.6rem;
border: 1px dashed var(--accent);
border-radius: 8px;
background: var(--panel-soft);
}
.style-sugg-text {
font-size: 0.76rem;
line-height: 1.4;
color: var(--text);
}
.style-sugg-text strong {
color: var(--accent);
}
.style-sugg-preview {
margin-top: 0.35rem;
font-size: 0.76rem;
line-height: 1.45;
color: var(--text-muted);
}
.style-sugg-actions {
display: flex;
gap: 6px;
margin-top: 0.45rem;
}
.sugg-ok,
.sugg-no {
padding: 4px 10px;
border-radius: 6px;
font-size: 0.74rem;
font-weight: 600;
cursor: pointer;
}
.sugg-ok {
border: none;
background: var(--accent);
color: var(--on-accent);
}
.sugg-no {
border: 1px solid var(--border-strong);
background: none;
color: var(--text-muted);
}
.sugg-no:hover {
border-color: var(--danger);
color: var(--danger);
}
.sugg-ok:disabled,
.sugg-no:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* --- KI-Prüfung --- */
.el-check {
margin-top: 1rem;
padding-top: 0.8rem;
border-top: 1px dashed var(--border-strong);
}
.check-btn {
width: 100%;
margin-bottom: 0.4rem;
padding: 7px 10px;
border: 1.5px dashed var(--border-strong);
border-radius: 8px;
background: var(--panel-soft);
color: var(--text-muted);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: all 0.12s;
}
.check-btn:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.check-btn:disabled {
opacity: 0.6;
cursor: wait;
}
.check-empty {
margin: 0.6rem 0 0;
font-size: 0.78rem;
color: var(--text-faint);
text-align: center;
}
.sugg-list {
list-style: none;
margin: 0.6rem 0 0;
padding: 0;
}
.sugg-list li {
display: flex;
align-items: flex-start;
gap: 7px;
padding: 0.4rem 0.2rem;
font-size: 0.8rem;
line-height: 1.4;
color: var(--text);
cursor: pointer;
border-radius: 6px;
}
.sugg-list li:hover {
background: var(--panel-soft);
}
.sugg-list input {
margin-top: 2px;
accent-color: var(--accent);
cursor: pointer;
}
.sugg-text em {
color: var(--text-faint);
font-style: normal;
font-size: 0.72rem;
}
.check-btn.busy {
border-style: solid;
border-color: var(--warning);
color: var(--warning);
animation: pulse 1.5s ease-in-out infinite;
}
.sugg-actions {
display: flex;
gap: 6px;
margin-top: 0.5rem;
}
.dismiss-btn {
padding: 7px 10px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: none;
color: var(--text-muted);
font-size: 0.8rem;
cursor: pointer;
}
.dismiss-btn:hover {
border-color: var(--danger);
color: var(--danger);
}
.apply-btn {
flex: 1;
padding: 7px 10px;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--on-accent);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
}
.apply-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Chat unten (Muster: TopicDetail-Chat) */
.el-chat {
display: flex;
flex-direction: column;
flex: 0 0 33%;
min-height: 0;
border-top: 1px solid var(--border);
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-hint {
color: var(--text-faint);
font-size: 0.78rem;
text-align: center;
margin-top: 0.5rem;
}
.chat-msg {
max-width: 85%;
padding: 6px 10px;
border-radius: 12px;
font-size: 0.82rem;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
}
.chat-msg.user {
align-self: flex-end;
background: var(--accent);
color: var(--on-accent);
border-bottom-right-radius: 3px;
}
.chat-msg.assistant {
align-self: flex-start;
background: var(--panel-soft);
color: var(--text);
border-bottom-left-radius: 3px;
}
.chat-typing {
color: var(--text-faint);
font-style: italic;
}
.chat-input {
display: flex;
gap: 6px;
padding: 0.6rem;
border-top: 1px solid var(--border);
}
.chat-input textarea {
flex: 1;
resize: none;
height: 72px;
padding: 8px 10px;
border: 1px solid var(--border-strong);
border-radius: 8px;
font-size: 0.85rem;
font-family: inherit;
background: var(--panel);
color: var(--text);
outline: none;
}
.chat-input textarea:focus {
border-color: var(--accent);
}
.chat-input button {
width: 38px;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--on-accent);
font-size: 1rem;
cursor: pointer;
}
.chat-input button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>

View File

@@ -1,37 +1,7 @@
<script setup>
import { computed, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { marked } from 'marked'
import { markedHighlight } from 'marked-highlight'
import hljs from 'highlight.js'
import 'highlight.js/styles/github-dark.css'
import DOMPurify from 'dompurify'
import { fetchGuideContent, chatGuide, fetchProgress, setProgress } from '../api.js'
marked.use(markedHighlight({
langPrefix: 'hljs language-',
highlight(code, lang) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value
}
return hljs.highlightAuto(code).value
},
}))
marked.setOptions({ breaks: true, gfm: true })
// Rohes HTML im Markdown (z. B. <p>, <img> ohne Backticks aus Agenten-Output)
// als Text anzeigen statt rendern — sonst verschluckt der Browser den Inhalt.
marked.use({
renderer: {
html(token) {
const text = typeof token === 'string' ? token : token.text
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
},
},
})
function renderMarkdown(text) {
return DOMPurify.sanitize(marked.parse(text || ''))
}
import { renderMarkdown } from '../markdown.js'
const props = defineProps({
previewGuide: { type: Object, default: null },
@@ -39,7 +9,7 @@ const props = defineProps({
provider: { type: String, default: 'claude' },
})
const emit = defineEmits(['progressChanged'])
const emit = defineEmits(['progressChanged', 'openElements'])
const isOnePager = computed(() => props.previewGuide?.format === 'OnePager')
@@ -247,6 +217,7 @@ async function send() {
</div>
<button v-if="previewGuide && !chatOpen" class="chat-fab" title="Fragen zum Guide" @click="openChat">💬</button>
<button v-if="previewGuide && !chatOpen" class="chat-fab elements-fab" title="Elemente öffnen" @click="emit('openElements')">🗂</button>
<div v-if="previewGuide && chatOpen" ref="panelEl" class="chat-panel">
<header class="chat-header">
@@ -632,6 +603,10 @@ async function send() {
background: var(--accent-hover);
}
.elements-fab {
right: 5.25rem;
}
.chat-panel {
position: fixed;
right: 1.5rem;

View File

@@ -19,7 +19,7 @@ const props = defineProps({
providers: { type: Array, default: () => [] },
})
const emit = defineEmits(['select', 'create', 'formatClick', 'bausteineClick', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'deleteProject', 'cancelGuide', 'deleteGuide', 'dismissError', 'preview', 'togglePin', 'sidebarLeave', 'toggleDark', 'setProvider'])
const emit = defineEmits(['select', 'create', 'formatClick', 'bausteineClick', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'deleteProject', 'cancelGuide', 'deleteGuide', 'dismissError', 'preview', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setProvider'])
function providerAvailable(id) {
const p = props.providers.find((x) => x.id === id)
@@ -334,6 +334,11 @@ function confirmDeleteProject(name) {
<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">
@@ -639,6 +644,19 @@ function confirmDeleteProject(name) {
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;

31
frontend/src/markdown.js Normal file
View File

@@ -0,0 +1,31 @@
import { marked } from 'marked'
import { markedHighlight } from 'marked-highlight'
import hljs from 'highlight.js'
import 'highlight.js/styles/github-dark.css'
import DOMPurify from 'dompurify'
marked.use(markedHighlight({
langPrefix: 'hljs language-',
highlight(code, lang) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value
}
return hljs.highlightAuto(code).value
},
}))
marked.setOptions({ breaks: true, gfm: true })
// Rohes HTML im Markdown (z. B. <p>, <img> ohne Backticks aus Agenten-Output)
// als Text anzeigen statt rendern — sonst verschluckt der Browser den Inhalt.
marked.use({
renderer: {
html(token) {
const text = typeof token === 'string' ? token : token.text
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
},
},
})
export function renderMarkdown(text) {
return DOMPurify.sanitize(marked.parse(text || ''))
}