Files
creator/frontend/src/components/TopicDetail.vue
2026-06-21 15:38:10 +02:00

762 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, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { fetchGuideContent, chatGuide, fetchBausteinLernstand } from '../api.js'
import { renderMarkdown } from '../markdown.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'])
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 scrollEl = ref(null)
const lernstand = ref({}) // Prüfungs-Stand pro Baustein-Titel — VOR dem immediate-Watch (loadContent nutzt es)
watch(() => props.previewGuide?.id, loadContent, { immediate: true })
async function loadContent() {
content.value = null
loadError.value = null
lernstand.value = {}
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
}
if (g.format !== 'OnePager') {
try {
lernstand.value = (await fetchBausteinLernstand(g.topic)).bausteine || {}
} catch { /* offline → leer */ }
}
}
// --- Baustein-Lernen: Prüfungs-Stand pro Baustein-Titel (lernstand oben deklariert) ---
function onBausteinStatus(baustein, status) {
const warAbsolviert = lernstand.value[baustein]?.absolviert
lernstand.value = { ...lernstand.value, [baustein]: status }
if (status.absolviert && !warAbsolviert) emit('progressChanged') // Locks/Stats neu laden
}
// --- 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(() =>
isOnePager.value || !content.value ? [] : content.value.chapters.flatMap((ch) => ch.sections),
)
const fokusBaustein = computed(() => (fokusIndex.value === null ? null : bausteine.value[fokusIndex.value]))
function openFokus(baustein, tab) {
const i = bausteine.value.findIndex((s) => s.title === baustein.title)
if (i === -1) return
fokusIndex.value = i
fokusTab.value = tab || 'pruefung'
}
function fokusPrev() { if (fokusIndex.value > 0) fokusIndex.value-- }
function fokusNext() { if (fokusIndex.value < bausteine.value.length - 1) fokusIndex.value++ }
// Erfahrungsleiste: Lernstand über alle Bausteine des Guides (sticky, absolviert ⊇ verstanden ⊇ gemeistert).
const fortschritt = computed(() => {
let a = 0, v = 0, g = 0
for (const s of bausteine.value) {
const l = lernstand.value[s.title] || {}
if (l.absolviert) a++
if (l.verstanden) v++
if (l.gemeistert) g++
}
return { total: bausteine.value.length, absolviert: a, verstanden: v, gemeistert: g }
})
// Score-Deckel folgt dem höchsten erstellten Format: Mini→6, Guide→12, ProGuide→18, FullGuide→30.
const cap = computed(() => {
const d = props.doneByFormat || {}
if (d.FullGuide) return 30
if (d.ProGuide) return 18
if (d.Guide) return 12
return 6
})
// --- 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" :class="{ onepager: isOnePager }">
<header class="guide-head">
<h1>{{ previewGuide.topic }}</h1>
<span class="guide-format">{{ previewGuide.format }}</span>
<span v-if="themaAbgeschlossen" class="thema-done" title="Alle FullGuide-Bausteine gemeistert (25/25)"> 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"
:class="['section-card', isOnePager && s.key ? 'op-card op-' + s.key : '', lernstand[s.title]?.gemeistert ? 'gemeistert' : (lernstand[s.title]?.verstanden ? 'verstanden' : (lernstand[s.title]?.absolviert ? 'absolviert' : ''))]"
:style="isOnePager && s.key ? { gridArea: s.key } : null"
>
<h3 :class="{ 'baustein-klick': !isOnePager }" @click="!isOnePager && openFokus(s, 'pruefung')">
{{ s.title }}
<span v-if="lernstand[s.title]?.gemeistert" class="baustein-done gemeistert" title="Gemeistert (25/25)"> Gemeistert</span>
<span v-else-if="lernstand[s.title]?.verstanden" class="baustein-done verstanden" title="Vollständig verstanden (10/10)"> Verstanden</span>
<span v-else-if="lernstand[s.title]?.absolviert" class="baustein-done" title="Prüfung bestanden"> Absolviert</span>
</h3>
<div class="section-body markdown" v-html="renderMarkdown(!isOnePager && ansichtModus === 'kompakt' ? (s.kompakt || s.md) : s.md)"></div>
<BausteinPanel
v-if="!isOnePager"
mode="trigger"
:baustein="s.title"
:status="lernstand[s.title]"
:cap="cap"
: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"
:provider="provider"
:fortschritt="fortschritt"
:status="lernstand[fokusBaustein.title]"
:cap="cap"
: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)"
/>
<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:not(.onepager) .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:not(.onepager) .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:not(.onepager) .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 (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;
}
}
/* Titel-Klick öffnet die Vollansicht. */
.section-card h3.baustein-klick { cursor: pointer; width: fit-content; }
.section-card h3.baustein-klick:hover { color: var(--accent); }
/* 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;
}
}
.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;
}
.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);
}
/* 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>