729 lines
21 KiB
Vue
729 lines
21 KiB
Vue
<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>
|