Files
creator/frontend/src/components/TopicDetail.vue
2026-06-24 11:56:12 +02:00

729 lines
21 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, reactive, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { fetchGuideContent, chatGuide, fetchBausteinLernstand } from '../api.js'
import { renderMarkdown } from '../markdown.js'
import { stufeFuer, schwelle } from '../stufen.js'
import { useChat } from '../composables/useChat.js'
import BausteinPanel from './BausteinPanel.vue'
import BausteinFokus from './BausteinFokus.vue'
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
doneByFormat: { type: Object, default: () => ({}) }, // Format → fertiger Guide (Themen-bezogen)
themaAbgeschlossen: { type: Boolean, default: false },
ansichtModus: { type: String, default: 'kompakt' }, // kompakt | erklärend
})
const emit = defineEmits(['progressChanged', 'setAnsicht', 'openSidebar', 'fokusActive'])
// Rotierende Kapitel-Akzentfarben (ohne Rot)
const CH_COLORS = ['#3b82f6', '#8b5cf6', '#14b8a6', '#f59e0b', '#22c55e', '#6366f1']
// --- Inhalt laden ---
const content = ref(null)
const loadError = ref(null)
const scrollEl = ref(null)
const lernstand = ref({}) // Prüfungs-Stand pro Baustein-Titel — VOR dem immediate-Watch (loadContent nutzt es)
// --- Lazy-Render + Markdown-Cache: nur sichtbare Sections parsen, jede nur einmal.
// Behebt das Blockieren beim Öffnen (160× marked/highlight.js) und Re-Parse bei jedem Update. ---
const mdCache = new Map() // `${modus}:${num}` → html
const sichtbar = reactive({}) // num → true (bleibt true, sobald je sichtbar)
let mdObserver = null
function htmlFor(s) {
const key = `${props.ansichtModus}:${s.num}`
let h = mdCache.get(key)
if (h === undefined) {
h = renderMarkdown(props.ansichtModus === 'kompakt' ? (s.kompakt || s.md) : s.md)
mdCache.set(key, h)
}
return h
}
// Sections rendern erst, wenn sie (fast) im Viewport sind — Observer auf den Scroll-Container.
function setupLazy() {
mdObserver?.disconnect()
if (!scrollEl.value) return
mdObserver = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue
sichtbar[Number(e.target.dataset.num)] = true
mdObserver.unobserve(e.target)
}
}, { root: scrollEl.value, rootMargin: '800px 0px' })
for (const el of scrollEl.value.querySelectorAll('.section-card')) mdObserver.observe(el)
}
watch(content, () => nextTick(setupLazy))
onUnmounted(() => mdObserver?.disconnect())
watch(() => props.previewGuide?.id, loadContent, { immediate: true })
async function loadContent() {
content.value = null
loadError.value = null
lernstand.value = {}
mdCache.clear()
for (const k in sichtbar) delete sichtbar[k]
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 {
lernstand.value = (await fetchBausteinLernstand(g.topic)).bausteine || {}
} catch { /* offline → leer */ }
}
// --- Baustein-Lernen: Prüfungs-Stand pro Baustein-Titel (lernstand oben deklariert) ---
function stufeVon(title) {
const l = lernstand.value[title]
return l ? stufeFuer(l.gute_antworten || 0, l.cap || 10) : null
}
function onBausteinStatus(baustein, status) {
const alt = stufeVon(baustein)?.key || null
lernstand.value = { ...lernstand.value, [baustein]: status }
const neu = stufeFuer(status.gute_antworten || 0, status.cap || 10)?.key || null
if (neu !== alt) emit('progressChanged') // Stufenwechsel → Locks/Stats neu laden
}
// Section nach Prüfen/Beheben/Neu-Schreiben im Content ersetzen + Markdown-Cache leeren.
function onSectionUpdated({ title, kompakt, md }) {
if (!content.value) return
for (const ch of content.value.chapters) {
for (const s of ch.sections) {
if (s.title !== title) continue
s.md = md
s.kompakt = kompakt
for (const k of [...mdCache.keys()]) if (k.endsWith(`:${s.num}`)) mdCache.delete(k)
}
}
}
// --- Vollbild-Fokus: ein Baustein groß, Guide links + Prüfung rechts ---
const fokusIndex = ref(null) // Index in der flachen Baustein-Liste; null = zu
const fokusTab = ref('pruefung')
const bausteine = computed(() =>
!content.value ? [] : content.value.chapters.flatMap((ch) => ch.sections),
)
const fokusBaustein = computed(() => (fokusIndex.value === null ? null : bausteine.value[fokusIndex.value]))
// Fokus-Zustand an App melden (für die Sidebar über dem Overlay); Themenwechsel schließt den Fokus.
watch(fokusIndex, (v) => emit('fokusActive', v !== null))
watch(() => props.previewGuide?.id, () => { fokusIndex.value = null })
function openFokus(baustein, tab) {
if (!istPruefbar(baustein)) return // reine Lese-Sections (Rest/Rand) öffnen keinen Prüfungs-Fokus
const i = bausteine.value.findIndex((s) => s.title === baustein.title)
if (i === -1) return
fokusIndex.value = i
fokusTab.value = tab || 'pruefung'
}
// Nur prüfbare Sections im Fokus anspringen (FullGuide hat dazwischen reine Lese-Sections).
function fokusPrev() {
for (let i = fokusIndex.value - 1; i >= 0; i--) if (istPruefbar(bausteine.value[i])) { fokusIndex.value = i; return }
}
function fokusNext() {
for (let i = fokusIndex.value + 1; i < bausteine.value.length; i++) if (istPruefbar(bausteine.value[i])) { fokusIndex.value = i; return }
}
// Erfahrungsleiste: kumulativer Stufen-Stand über alle Bausteine (gold ⊆ lila ⊆ blau ⊆ grün).
const fortschritt = computed(() => {
const z = { total: bausteine.value.length, anfaenger: 0, fortgeschritten: 0, experte: 0, meister: 0 }
for (const s of bausteine.value) {
const l = lernstand.value[s.title]
if (!l) continue
const sc = l.gute_antworten || 0, cp = l.cap || 10
if (sc >= schwelle(0.2, cp)) z.anfaenger++
if (sc >= schwelle(0.4, cp)) z.fortgeschritten++
if (sc >= schwelle(0.6, cp)) z.experte++
if (sc >= schwelle(1.0, cp)) z.meister++
}
return z
})
// cap je Baustein = 4×relevante Subbausteine (vom Backend in lernstand[title].cap geliefert).
function capVon(title) {
return lernstand.value[title]?.cap || 10
}
// Section prüfbar? Rest nie. Sonst prüfbar, außer das Feld ist explizit false (FullGuide-Rand).
// Fehlt das Feld (alte Guides ohne `pruefbar`) → prüfbar, damit Bestands-Guides weiter funktionieren.
function istPruefbar(s) {
return props.previewGuide?.format !== 'Rest' && s.pruefbar !== false
}
// --- Chat (Mechanik in useChat; Kontext-Extraktion bleibt hier) ---
const chat = useChat((msgs) => {
const { section, outline } = extractContext()
return chatGuide(props.previewGuide.id, {
section, outline, messages: msgs, provider: props.provider,
})
})
const { messages, input, loading, messagesEl, inputEl, onScroll, send } = chat
const autoGrow = () => chat.autoGrow()
const chatOpen = ref(false)
const panelEl = ref(null)
function openChat() {
chatOpen.value = true
nextTick(() => inputEl.value?.focus())
}
function closeChat() {
chatOpen.value = false
chat.reset()
}
// Mobil schließen sich Chat und Elemente-Sidebar gegenseitig aus —
// nebeneinander ist kein Platz, die Sidebar würde den Chat überdecken.
watch(() => props.elementsOpen, (open) => {
if (open && chatOpen.value && window.matchMedia('(max-width: 768px)').matches) closeChat()
})
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 }
}
</script>
<template>
<div class="detail">
<div v-if="previewGuide && content" ref="scrollEl" class="guide-scroll">
<div class="guide-content">
<header class="guide-head">
<h1>{{ previewGuide.topic }}</h1>
<span class="guide-format">{{ previewGuide.format }}</span>
<span v-if="themaAbgeschlossen" class="thema-done" title="Alle Bausteine auf Meister"> Thema abgeschlossen</span>
</header>
<section
v-for="(ch, ci) in content.chapters"
:key="ch.title"
class="chapter"
: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"
:data-num="s.num"
class="section-card"
:style="stufeVon(s.title) ? { borderLeftColor: stufeVon(s.title).farbe } : {}"
>
<h3 :class="{ 'baustein-klick': istPruefbar(s) }" @click="istPruefbar(s) && openFokus(s, 'pruefung')">
{{ s.title }}
<template v-if="istPruefbar(s) && stufeVon(s.title)">
<span class="baustein-done" :style="{ color: stufeVon(s.title).farbe, borderColor: stufeVon(s.title).farbe }" :title="`${stufeVon(s.title).label} (${capVon(s.title)})`">{{ stufeVon(s.title).kurz }} {{ stufeVon(s.title).label }}</span>
</template>
</h3>
<div v-if="sichtbar[s.num]" class="section-body markdown" v-html="htmlFor(s)"></div>
<div v-else class="section-body skeleton"></div>
<BausteinPanel
v-if="istPruefbar(s)"
mode="trigger"
:baustein="s.title"
:status="lernstand[s.title]"
:cap="capVon(s.title)"
:topic="previewGuide.topic"
@open-fokus="(tab) => openFokus(s, tab)"
/>
</article>
</div>
</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>
<BausteinFokus
v-if="fokusBaustein"
:baustein="fokusBaustein"
:topic="previewGuide.topic"
:guide-id="previewGuide.id"
:provider="provider"
:fortschritt="fortschritt"
:status="lernstand[fokusBaustein.title]"
:cap="capVon(fokusBaustein.title)"
:tab="fokusTab"
:ansicht="ansichtModus"
:has-prev="fokusIndex > 0"
:has-next="fokusIndex < bausteine.length - 1"
@prev="fokusPrev"
@next="fokusNext"
@close="fokusIndex = null"
@set-ansicht="$emit('setAnsicht', $event)"
@status-changed="(st) => onBausteinStatus(st.baustein, st)"
@open-sidebar="$emit('openSidebar')"
@section-updated="onSectionUpdated"
/>
<button v-if="previewGuide && !chatOpen && fokusIndex === null" class="chat-fab" :class="{ shifted: elementsOpen }" title="Fragen zum Guide" @click="openChat">💬</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" @scroll="onScroll">
<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"
rows="3"
placeholder="Frage stellen"
@input="autoGrow"
@keydown.enter.exact.prevent="send"
></textarea>
<button
:disabled="!input.trim() && !loading"
:class="{ cancel: loading }"
:title="loading ? 'Abbrechen' : 'Senden'"
@click="send"
>{{ loading ? '✕' : '➤' }}</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: 100dvh;
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;
}
@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;
}
.thema-done {
font-size: 0.8rem;
font-weight: 600;
padding: 0.15rem 0.6rem;
border-radius: 999px;
background: color-mix(in srgb, #d4af37 20%, var(--panel));
border: 1px solid #d4af37;
color: #8a6d12;
}
.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;
}
.section-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem 1.25rem;
margin-bottom: 0.75rem;
}
.baustein-done {
float: right;
margin-left: 0.5rem;
padding: 0.12rem 0.6rem;
font-size: 0.68em;
font-weight: 600;
line-height: 1.5;
border-radius: 999px;
background: var(--success-soft);
border: 1px solid var(--success-border);
color: var(--success);
white-space: nowrap;
}
/* Absolvierte Bausteine: Karte kippt sichtbar auf Grün */
.guide-content .section-card.absolviert {
border-color: var(--success-border);
border-top: 3px solid var(--success);
background: color-mix(in srgb, var(--success) 5%, var(--panel));
}
/* Verstandene Bausteine (10/10): Lila */
.baustein-done.verstanden {
background: color-mix(in srgb, #8b5cf6 16%, var(--panel));
border-color: #8b5cf6;
color: #6d28d9;
}
.guide-content .section-card.verstanden {
border-color: #8b5cf6;
border-top: 3px solid #8b5cf6;
background: color-mix(in srgb, #8b5cf6 7%, var(--panel));
}
/* Gemeisterte Bausteine (Meisterpfad 25/25): Gold */
.baustein-done.gemeistert {
background: color-mix(in srgb, #d4af37 20%, var(--panel));
border-color: #d4af37;
color: #8a6d12;
}
.guide-content .section-card.gemeistert {
border-color: #d4af37;
border-top: 3px solid #d4af37;
background: color-mix(in srgb, #d4af37 8%, var(--panel));
}
/* Guides: Karten tragen die Kapitel-Akzentfarbe */
.guide-content .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;
}
}
/* Titel-Klick öffnet die Vollansicht. */
.section-card h3.baustein-klick { cursor: pointer; width: fit-content; }
.section-card h3.baustein-klick:hover { color: var(--accent); }
/* Platzhalter für noch nicht gerenderte Sections (Lazy-Render). Stabile Höhe,
damit der IntersectionObserver die folgenden Karten gestaffelt erkennt. */
.section-body.skeleton { min-height: 160px; }
.empty-preview {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
}
/* --- Markdown: Basis global (assets/markdown.css), hier nur Lese-Ansicht-Overrides --- */
/* Breite Lese-Ansicht: Code scrollt horizontal statt umzubrechen */
.markdown :deep(pre) {
white-space: pre;
overflow-wrap: normal;
overflow-x: auto;
}
/* „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;
}
/* 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;
}
/* --- 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);
}
/* Element-Sidebar (320px) offen → Chat links daneben anzeigen */
.chat-fab.shifted {
right: calc(1.5rem + 320px);
}
.chat-panel.shifted {
right: calc(1.5rem + 320px);
}
/* Mobil liegt die Elemente-Sidebar als Overlay über dem Chat — FAB/Panel ausblenden */
@media (max-width: 768px) {
.chat-fab.shifted,
.chat-panel.shifted {
display: none;
}
}
.chat-panel {
position: fixed;
right: 1.5rem;
bottom: 1.5rem;
width: 360px;
height: 500px;
max-height: calc(100dvh - 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;
align-items: stretch;
gap: 6px;
padding: 0.6rem;
border-top: 1px solid var(--border);
}
.chat-input textarea {
flex: 1;
resize: none;
min-height: 72px;
max-height: 200px;
overflow-y: auto;
padding: 8px 10px;
border: 1px solid var(--border-strong);
border-radius: 8px;
font-size: 0.85rem;
font-family: inherit;
line-height: 1.4;
outline: none;
}
.chat-input textarea:focus {
border-color: var(--accent);
}
.chat-input button {
width: 38px;
flex-shrink: 0;
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;
}
.chat-input button.cancel {
background: var(--danger);
}
</style>