Files
creator/frontend/src/components/TopicDetail.vue
2026-06-07 15:27:07 +02:00

745 lines
18 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, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { fetchGuideContent, chatGuide, fetchProgress, setProgress } from '../api.js'
import { renderMarkdown } from '../markdown.js'
const props = defineProps({
previewGuide: { type: Object, default: null },
dark: { type: Boolean, default: false },
provider: { type: String, default: 'claude' },
elementsOpen: { type: Boolean, default: false }, // Element-Sidebar offen → Chat nach links
})
const emit = defineEmits(['progressChanged', 'openElements'])
const isOnePager = computed(() => props.previewGuide?.format === 'OnePager')
// Rotierende Kapitel-Akzentfarben (passend zum OnePager-Cheat-Sheet, ohne Rot)
const CH_COLORS = ['#3b82f6', '#8b5cf6', '#14b8a6', '#f59e0b', '#22c55e', '#6366f1']
// --- Inhalt laden ---
const content = ref(null)
const loadError = ref(null)
const doneChapters = ref(new Set())
const scrollEl = ref(null)
watch(() => props.previewGuide?.id, loadContent, { immediate: true })
async function loadContent() {
content.value = null
loadError.value = null
doneChapters.value = new Set()
const g = props.previewGuide
if (!g || g.status !== 'done') return
try {
content.value = await fetchGuideContent(g.id)
} catch (e) {
console.error('Fehler beim Laden des Guides:', e)
loadError.value = 'Inhalt nicht verfügbar — die Datei fehlt. Guide neu generieren (▶).'
return
}
try {
const res = await fetchProgress(g.id)
doneChapters.value = new Set(res.chapters || [])
} catch { /* offline → leer */ }
nextTick(scrollToFirstOpen)
}
// Zum ersten noch offenen Kapitel springen — aber nur, wenn schon etwas erledigt ist.
function scrollToFirstOpen() {
if (!doneChapters.value.size || !content.value) return
const chapters = Array.from(scrollEl.value?.querySelectorAll('section.chapter') || [])
const firstOpen = chapters.find((el) => !el.classList.contains('ch-complete'))
if (firstOpen && firstOpen !== chapters[0]) firstOpen.scrollIntoView({ block: 'start' })
}
// --- Kapitel-Fortschritt ---
async function toggleChapter(title) {
const newState = !doneChapters.value.has(title)
const optimistic = new Set(doneChapters.value)
if (newState) optimistic.add(title)
else optimistic.delete(title)
doneChapters.value = optimistic
try {
const res = await setProgress(props.previewGuide.id, title, newState)
doneChapters.value = new Set(res.chapters || [])
emit('progressChanged')
} catch {
const rollback = new Set(doneChapters.value)
if (newState) rollback.delete(title)
else rollback.add(title)
doneChapters.value = rollback
}
}
// --- Chat ---
const chatOpen = ref(false)
const messages = ref([])
const input = ref('')
const loading = ref(false)
const messagesEl = ref(null)
const inputEl = ref(null)
const panelEl = ref(null)
function openChat() {
chatOpen.value = true
nextTick(() => inputEl.value?.focus())
}
function closeChat() {
chatOpen.value = false
messages.value = []
input.value = ''
}
function onDocMouseDown(e) {
if (!chatOpen.value) return
if (panelEl.value && panelEl.value.contains(e.target)) return
closeChat()
}
// Enter öffnet den Chat (wenn zu, nicht in Eingabefeld); ESC schließt ihn
function onDocKeyDown(e) {
if (e.key === 'Escape' && chatOpen.value) {
e.preventDefault()
closeChat()
return
}
if (e.key !== 'Enter' || chatOpen.value || !props.previewGuide) return
const tag = document.activeElement?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
e.preventDefault()
openChat()
}
onMounted(() => {
document.addEventListener('mousedown', onDocMouseDown, true)
document.addEventListener('keydown', onDocKeyDown)
})
onUnmounted(() => {
document.removeEventListener('mousedown', onDocMouseDown, true)
document.removeEventListener('keydown', onDocKeyDown)
})
function extractContext() {
if (!content.value) return { section: '', outline: '' }
const outline = content.value.chapters
.map((ch) => [ch.title, ...ch.sections.map((s) => ' ' + s.title)].join('\n'))
.join('\n')
.slice(0, 7000)
// Aktuelle Section = letzte Karte, deren Oberkante oben im Viewport oder darüber liegt
let section = ''
const cards = Array.from(scrollEl.value?.querySelectorAll('.section-card') || [])
let current = null
for (const el of cards) {
if (el.getBoundingClientRect().top <= 120) current = el
else break
}
if (!current && cards.length) current = cards[0]
if (current) section = current.innerText.trim().slice(0, 18000)
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,
provider: props.provider,
})
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 v-if="previewGuide && content" ref="scrollEl" class="guide-scroll">
<div class="guide-content" :class="{ onepager: isOnePager }">
<header class="guide-head">
<h1>{{ previewGuide.topic }}</h1>
<span class="guide-format">{{ previewGuide.format }}</span>
</header>
<section
v-for="(ch, ci) in content.chapters"
:key="ch.title"
class="chapter"
:class="{ 'ch-complete': doneChapters.has(ch.title) }"
:style="{ '--ch-accent': CH_COLORS[ci % CH_COLORS.length] }"
>
<h2 class="chapter-title"><span class="ch-num">{{ ci + 1 }}</span>{{ ch.title }}</h2>
<div class="sections">
<article
v-for="s in ch.sections"
:key="s.num"
:class="['section-card', isOnePager && s.key ? 'op-card op-' + s.key : '']"
:style="isOnePager && s.key ? { gridArea: s.key } : null"
>
<h3>{{ s.title }}</h3>
<div class="section-body markdown" v-html="renderMarkdown(s.md)"></div>
</article>
</div>
<button
v-if="!isOnePager"
class="ch-toggle"
:class="{ 'is-done': doneChapters.has(ch.title) }"
@click="toggleChapter(ch.title)"
>{{ doneChapters.has(ch.title) ? '✓ Erledigt rückgängig' : 'Kapitel als erledigt markieren' }}</button>
</section>
</div>
</div>
<div v-else-if="previewGuide" class="empty-preview">
<p>{{ loadError || 'Lade Inhalt…' }}</p>
</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" :class="{ shifted: elementsOpen }" title="Fragen zum Guide" @click="openChat">💬</button>
<button v-if="previewGuide && !chatOpen && !elementsOpen" class="chat-fab elements-fab" title="Elemente öffnen" @click="emit('openElements')">🗂</button>
<div v-if="previewGuide && chatOpen" ref="panelEl" class="chat-panel" :class="{ shifted: elementsOpen }">
<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;
/* Flex-Item darf schmaler werden als seine Code-Blöcke — sonst sprengt
deren Mindestbreite auf Mobile das Layout */
min-width: 0;
height: 100vh;
position: relative;
}
.guide-scroll {
height: 100%;
overflow-y: auto;
/* Kein horizontales Pannen der ganzen Seite — Code-Blöcke scrollen intern */
overflow-x: hidden;
background: var(--bg-preview);
}
.guide-content {
max-width: 880px;
margin: 0 auto;
padding: 2rem 2.5rem 5rem;
/* Lese-Zoom nur für den Inhalt — Sidebar/Chat bleiben unverändert */
zoom: 1.2;
}
@media (max-width: 600px) {
.guide-content {
padding: 1.25rem 0.9rem 4rem;
}
}
.guide-head {
display: flex;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 1.5rem;
h1 {
font-size: 1.7rem;
}
}
.guide-format {
color: var(--text-faint);
font-size: 0.9rem;
font-weight: 600;
}
.chapter {
margin-bottom: 2.5rem;
}
.chapter-title {
font-size: 1.25rem;
margin-bottom: 0.9rem;
padding-bottom: 0.4rem;
border-bottom: 2px solid color-mix(in srgb, var(--ch-accent, var(--accent)) 35%, transparent);
display: flex;
align-items: center;
gap: 10px;
}
.ch-num {
flex: 0 0 auto;
width: 28px;
height: 28px;
border-radius: 8px;
background: var(--ch-accent, var(--accent));
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.85rem;
font-weight: 700;
}
.chapter.ch-complete .sections {
opacity: 0.4;
}
.section-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem 1.25rem;
margin-bottom: 0.75rem;
}
/* Guides: Karten tragen die Kapitel-Akzentfarbe (OnePager hat eigene op-card-Farben) */
.guide-content:not(.onepager) .section-card {
border-top: 3px solid color-mix(in srgb, var(--ch-accent, var(--accent)) 65%, transparent);
background: color-mix(in srgb, var(--ch-accent, var(--accent)) 3%, var(--panel));
}
.section-card {
h3 {
font-size: 1.02rem;
margin-bottom: 0.5rem;
}
}
/* OnePager: festes 3×3-Raster über volle Breite und Höhe.
Kein Lese-Zoom (bricht 100%-Höhen) — stattdessen sind die Schriften unten 1.2× skaliert. */
.guide-content.onepager {
max-width: none;
height: 100%;
zoom: 1;
padding: 0.9rem 1rem;
display: flex;
flex-direction: column;
}
.guide-content.onepager .guide-head,
.guide-content.onepager .chapter-title {
display: none; /* Thema steht in der Info-Karte — Platz fürs Raster */
}
.guide-content.onepager .chapter {
flex: 1;
min-height: 0;
margin-bottom: 0;
}
.guide-content.onepager .sections {
height: 100%;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
grid-template-areas:
"info beispiel voraussetzungen"
"eigenschaften beispiel modern"
"eigenschaften zusammenhaenge veraltet";
gap: 0.6rem;
}
.guide-content.onepager .section-card {
margin-bottom: 0;
padding: 0.7rem 0.9rem;
min-height: 0;
overflow-y: auto;
h3 {
font-size: 1.06rem;
margin-bottom: 0.3rem;
}
.section-body {
font-size: 0.98rem;
}
}
/* Farbkodiertes Cheat-Sheet: feste Akzentfarbe + Icon pro Karte */
.op-info { --op-accent: #3b82f6; }
.op-eigenschaften { --op-accent: #8b5cf6; --op-icon: "☰"; }
.op-beispiel { --op-accent: #64748b; --op-icon: ""; }
.op-zusammenhaenge { --op-accent: #14b8a6; --op-icon: "⇄"; }
.op-voraussetzungen { --op-accent: #f59e0b; --op-icon: "✓"; }
.op-modern { --op-accent: #22c55e; --op-icon: "✦"; }
.op-veraltet { --op-accent: #ef4444; --op-icon: "⚠"; }
.guide-content.onepager .section-card.op-card {
border-top: 3px solid var(--op-accent);
background: color-mix(in srgb, var(--op-accent) 5%, var(--panel));
h3 {
color: var(--op-accent);
font-size: 0.95rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
h3::before {
content: var(--op-icon, "");
margin-right: 7px;
}
}
/* Info-Karte: Thema als große Headline statt Uppercase-Label */
.guide-content.onepager .op-card.op-info h3 {
font-size: 1.5rem;
text-transform: none;
letter-spacing: 0;
color: var(--text);
}
/* Mobil: eine Spalte in Quellreihenfolge (info → … → veraltet) */
@media (max-width: 900px) {
.guide-content.onepager {
height: auto;
}
.guide-content.onepager .sections {
height: auto;
display: flex;
flex-direction: column;
}
.guide-content.onepager .section-card {
overflow-y: visible;
}
}
.ch-toggle {
display: block;
width: 100%;
margin-top: 0.5rem;
padding: 0.8rem 1rem;
border: 1.5px dashed var(--border-strong);
border-radius: 10px;
background: var(--panel-soft);
color: var(--text-muted);
font: 600 0.9rem/1.2 inherit;
font-family: inherit;
text-align: center;
cursor: pointer;
transition: all 0.12s;
&:hover {
border-color: var(--accent);
color: var(--accent);
background: transparent;
}
&.is-done {
border-style: solid;
border-color: var(--success-border);
background: var(--success-soft);
color: var(--success);
}
}
.empty-preview {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
}
/* --- Markdown (Sections + Chat) --- */
.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(li) {
margin: 0.15em 0;
}
.markdown :deep(code) {
background: var(--border);
padding: 1px 4px;
border-radius: 4px;
font-family: "SF Mono", Consolas, monospace;
font-size: 0.85em;
/* Lange Bezeichner (Namespaces, Pfade) dürfen umbrechen statt zu überlaufen */
overflow-wrap: anywhere;
}
.markdown :deep(pre) {
background: var(--code-bg, #1e2330);
color: var(--code-fg, #e6e8ee);
padding: 10px 12px;
border-radius: 8px;
overflow-x: auto;
margin: 0.5em 0;
}
.markdown :deep(pre code) {
background: none;
padding: 0;
color: inherit;
font-size: 0.85em;
}
.markdown :deep(h1),
.markdown :deep(h2),
.markdown :deep(h3) {
font-size: 0.95em;
margin: 0.6em 0 0.3em;
}
/* „Beispiel"-Überschriften in Karten als dezentes Uppercase-Label */
.section-card .markdown :deep(h3) {
font-size: 0.74em;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
margin: 0.9em 0 0.35em;
}
.markdown :deep(a) {
color: var(--accent-hover);
}
.markdown :deep(table) {
border-collapse: collapse;
font-size: 0.95em;
}
.markdown :deep(th),
.markdown :deep(td) {
border: 1px solid var(--border-strong);
padding: 2px 6px;
}
/* Lesbarkeit: ~17px Fließtext, Zeilenhöhe 1.6, Textspalte max. ~70 Zeichen —
Code-Blöcke dürfen die volle Kartenbreite nutzen */
.section-body {
font-size: 1.0625rem;
line-height: 1.6;
}
.section-card .markdown :deep(p),
.section-card .markdown :deep(ul),
.section-card .markdown :deep(ol) {
max-width: 70ch;
}
.onepager .section-card .markdown :deep(p),
.onepager .section-card .markdown :deep(ul),
.onepager .section-card .markdown :deep(ol) {
max-width: none; /* OnePager-Zellen sind selbst schmal genug */
}
/* --- Chat --- */
.chat-fab {
position: fixed;
right: 1.5rem;
bottom: 1.5rem;
width: 52px;
height: 52px;
border: none;
border-radius: 50%;
background: var(--accent);
color: var(--on-accent);
font-size: 1.4rem;
cursor: pointer;
box-shadow: 0 2px 12px var(--shadow);
z-index: 20;
}
.chat-fab:hover {
background: var(--accent-hover);
}
.elements-fab {
right: 5.25rem;
}
/* Element-Sidebar (320px) offen → Chat links daneben anzeigen */
.chat-fab.shifted {
right: calc(1.5rem + 320px);
}
.chat-panel.shifted {
right: calc(1.5rem + 320px);
}
.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: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 4px 24px var(--shadow);
z-index: 20;
overflow: hidden;
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 0.9rem;
background: var(--accent);
color: var(--on-accent);
font-weight: 600;
font-size: 0.9rem;
}
.chat-close {
border: none;
background: none;
color: var(--on-accent);
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: var(--text-faint);
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: 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-msg.markdown {
white-space: normal;
}
.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: 38px;
padding: 8px 10px;
border: 1px solid var(--border-strong);
border-radius: 8px;
font-size: 0.85rem;
font-family: inherit;
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>