Files
guides/frontend/src/components/TopicDetail.vue
2026-06-01 14:29:33 +02:00

431 lines
9.2 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 { computed, ref, nextTick } from 'vue'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import { htmlUrl, chatGuide } from '../api.js'
marked.setOptions({ breaks: true, gfm: true })
function renderMarkdown(text) {
return DOMPurify.sanitize(marked.parse(text || ''))
}
const props = defineProps({
previewGuide: { type: Object, default: null },
})
const LANDSCAPE_FORMATS = ['OnePager', 'Cheatsheet']
const isLandscape = computed(() => LANDSCAPE_FORMATS.includes(props.previewGuide?.format))
const frameEl = ref(null)
function injectPadding(e) {
if (isLandscape.value) return
const doc = e.target.contentDocument
if (!doc) return
const style = doc.createElement('style')
style.textContent = `@media screen {
html { background: #f0f1f4; }
body {
zoom: 1.15;
max-width: 1000px;
margin: 0 auto;
padding: 2.5rem 3.5rem;
background: #fff;
box-shadow: 0 1px 8px rgba(0, 0, 0, 0.08);
}
}`
doc.head?.appendChild(style)
}
// --- Chat ---
const chatOpen = ref(false)
const messages = ref([])
const input = ref('')
const loading = ref(false)
const messagesEl = ref(null)
const inputEl = ref(null)
function openChat() {
chatOpen.value = true
nextTick(() => inputEl.value?.focus())
}
function closeChat() {
chatOpen.value = false
messages.value = []
input.value = ''
}
function extractContext() {
try {
const doc = frameEl.value?.contentDocument
if (!doc) return { section: '', outline: '' }
const headings = Array.from(doc.querySelectorAll('h1, h2, h3'))
if (!headings.length) return { section: '', outline: '' }
const outline = headings.map((h) => h.innerText.trim()).filter(Boolean).join('\n')
// Aktuelle Überschrift = letzte, die oben im sichtbaren Bereich oder darüber liegt
let current = headings[0]
for (const h of headings) {
if (h.getBoundingClientRect().top <= 100) current = h
else break
}
const level = Number(current.tagName[1])
const idx = headings.indexOf(current)
let end = null
for (let i = idx + 1; i < headings.length; i++) {
if (Number(headings[i].tagName[1]) <= level) { end = headings[i]; break }
}
const range = doc.createRange()
range.setStartBefore(current)
if (end) range.setEndBefore(end)
else range.setEndAfter(doc.body.lastElementChild || doc.body)
const section = range.toString().trim().slice(0, 18000)
return { section, outline: outline.slice(0, 7000) }
} catch {
return { section: '', outline: '' }
}
}
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 || !props.previewGuide) return
messages.value.push({ role: 'user', content: text })
input.value = ''
loading.value = true
scrollToBottom()
try {
const { section, outline } = extractContext()
const res = await chatGuide(props.previewGuide.id, {
section,
outline,
messages: messages.value,
})
messages.value.push({ role: 'assistant', content: res.reply || '…' })
} catch {
messages.value.push({ role: 'assistant', content: 'Fehler bei der Anfrage.' })
} finally {
loading.value = false
scrollToBottom()
nextTick(() => inputEl.value?.focus())
}
}
</script>
<template>
<div class="detail">
<div class="preview" :class="{ landscape: isLandscape }" v-if="previewGuide">
<iframe ref="frameEl" :src="htmlUrl(previewGuide.id)" class="preview-frame" :class="{ landscape: isLandscape }" title="Guide-Vorschau" @load="injectPadding"></iframe>
</div>
<div class="empty-preview" v-else>
<p>Guide-Format anklicken um zu generieren oder Vorschau zu öffnen.</p>
</div>
<button v-if="previewGuide && !chatOpen" class="chat-fab" title="Fragen zum Guide" @click="openChat">💬</button>
<div v-if="previewGuide && chatOpen" class="chat-panel">
<header class="chat-header">
<span>Fragen zum Guide</span>
<button class="chat-close" title="Chat beenden" @click="closeChat">×</button>
</header>
<div ref="messagesEl" class="chat-messages">
<p v-if="!messages.length" class="chat-hint">Stell eine Frage zum aktuellen Abschnitt.</p>
<template v-for="(m, i) in messages" :key="i">
<div v-if="m.role === 'assistant'" class="chat-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
<div v-else class="chat-msg user">{{ m.content }}</div>
</template>
<div v-if="loading" class="chat-msg assistant chat-typing">Denkt</div>
</div>
<div class="chat-input">
<textarea
ref="inputEl"
v-model="input"
placeholder="Frage stellen…"
@keydown.enter.exact.prevent="send"
></textarea>
<button :disabled="!input.trim() || loading" @click="send"></button>
</div>
</div>
</div>
</template>
<style scoped>
.detail {
flex: 1;
height: 100vh;
}
.preview {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
background: #f0f1f4;
}
.preview.landscape {
padding: 2rem;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.4rem 1rem;
background: #f8f9fb;
border-bottom: 1px solid #e2e5e9;
font-size: 0.85rem;
font-weight: 600;
color: #5a6470;
flex-shrink: 0;
}
.preview-actions {
display: flex;
gap: 0.75rem;
}
.preview-link {
color: #6366f1;
text-decoration: none;
font-size: 0.8rem;
}
.preview-link:hover {
text-decoration: underline;
}
.preview-frame {
width: 100%;
flex: 1;
border: none;
background: #fff;
}
.preview-frame.landscape {
max-width: 1180px;
box-shadow: 0 1px 8px rgba(0, 0, 0, 0.08);
}
.empty-preview {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #5a6470;
}
.chat-fab {
position: fixed;
right: 1.5rem;
bottom: 1.5rem;
width: 52px;
height: 52px;
border: none;
border-radius: 50%;
background: #6366f1;
color: #fff;
font-size: 1.4rem;
cursor: pointer;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
z-index: 20;
}
.chat-fab:hover {
background: #4f46e5;
}
.chat-panel {
position: fixed;
right: 1.5rem;
bottom: 1.5rem;
width: 360px;
height: 500px;
max-height: calc(100vh - 3rem);
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid #e2e5e9;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.18);
z-index: 20;
overflow: hidden;
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 0.9rem;
background: #6366f1;
color: #fff;
font-weight: 600;
font-size: 0.9rem;
}
.chat-close {
border: none;
background: none;
color: #fff;
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
padding: 0 4px;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 0.9rem;
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-hint {
color: #9aa3af;
font-size: 0.82rem;
text-align: center;
margin-top: 1rem;
}
.chat-msg {
max-width: 85%;
padding: 7px 11px;
border-radius: 12px;
font-size: 0.85rem;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
}
.chat-msg.user {
align-self: flex-end;
background: #6366f1;
color: #fff;
border-bottom-right-radius: 3px;
}
.chat-msg.assistant {
align-self: flex-start;
background: #f1f3f7;
color: #1a1a1a;
border-bottom-left-radius: 3px;
}
.chat-typing {
color: #9aa3af;
font-style: italic;
}
.chat-msg.markdown :deep(p) {
margin: 0 0 0.5em;
}
.chat-msg.markdown :deep(p:last-child) {
margin-bottom: 0;
}
.chat-msg.markdown :deep(ul),
.chat-msg.markdown :deep(ol) {
margin: 0.3em 0;
padding-left: 1.2em;
}
.chat-msg.markdown :deep(li) {
margin: 0.15em 0;
}
.chat-msg.markdown :deep(code) {
background: #e4e7ee;
padding: 1px 4px;
border-radius: 4px;
font-family: "SF Mono", Consolas, monospace;
font-size: 0.8em;
}
.chat-msg.markdown :deep(pre) {
background: #1e2330;
color: #e6e8ee;
padding: 8px 10px;
border-radius: 8px;
overflow-x: auto;
margin: 0.5em 0;
}
.chat-msg.markdown :deep(pre code) {
background: none;
padding: 0;
color: inherit;
font-size: 0.78em;
}
.chat-msg.markdown :deep(h1),
.chat-msg.markdown :deep(h2),
.chat-msg.markdown :deep(h3) {
font-size: 0.95em;
margin: 0.4em 0 0.2em;
}
.chat-msg.markdown :deep(a) {
color: #4f46e5;
}
.chat-msg.markdown :deep(table) {
border-collapse: collapse;
font-size: 0.95em;
}
.chat-msg.markdown :deep(th),
.chat-msg.markdown :deep(td) {
border: 1px solid #d8dde3;
padding: 2px 6px;
}
.chat-input {
display: flex;
gap: 6px;
padding: 0.6rem;
border-top: 1px solid #e2e5e9;
}
.chat-input textarea {
flex: 1;
resize: none;
height: 38px;
padding: 8px 10px;
border: 1px solid #d8dde3;
border-radius: 8px;
font-size: 0.85rem;
font-family: inherit;
outline: none;
}
.chat-input textarea:focus {
border-color: #6366f1;
}
.chat-input button {
width: 38px;
border: none;
border-radius: 8px;
background: #6366f1;
color: #fff;
font-size: 1rem;
cursor: pointer;
}
.chat-input button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>