This commit is contained in:
team3
2026-06-27 00:58:46 +02:00
parent 2a2026c9ae
commit f018ca5048
44 changed files with 3070 additions and 379 deletions

View File

@@ -7,6 +7,7 @@ import TopicDetail from './components/TopicDetail.vue'
import BausteineUebersicht from './components/BausteineUebersicht.vue'
import ElementsSidebar from './components/elements/ElementsSidebar.vue'
import ElementsOverview from './components/ElementsOverview.vue'
import GeneralExamPanel from './components/GeneralExamPanel.vue'
const guides = ref([])
const backendTopics = ref([])
@@ -27,7 +28,9 @@ const provider = ref(localStorage.getItem('provider') || 'claude')
const providers = ref([])
const folders = ref({ projekt: [], uni: [] }) // Ordner für die Quellen-Auswahl
const bausteineView = ref(false) // Bausteine-Übersicht im Hauptbereich
const allgemeinView = ref(false) // Allgemeine Prüfung (themenweit) im Hauptbereich
const ansichtModus = ref('kompakt') // kompakt | erklärend — je Thema, Default kompakt
const stufeAnsicht = ref(Number(localStorage.getItem('stufe')) || 4) // 1=A · 2=F · 3=E · 4=V (Stufen-Ansicht)
const stats = ref(null)
const fortschritt = ref({})
const locks = ref({}) // Sperr-Gründe pro Format (Backend = einzige Regel-Quelle)
@@ -192,6 +195,7 @@ function selectTopic(topic) {
elementsOpen.value = false
elementsView.value = false
bausteineView.value = true // Thema-Klick → Baustein-Übersicht (Guide erst auf Pill-Klick)
allgemeinView.value = false
elementOpenId.value = null
ansichtModus.value = localStorage.getItem('ansicht_' + topic) === 'erklärend' ? 'erklärend' : 'kompakt'
localStorage.setItem('lastTopic', topic)
@@ -266,10 +270,16 @@ function setAnsicht(modus) {
if (selectedTopic.value) localStorage.setItem('ansicht_' + selectedTopic.value, modus)
}
function setStufe(k) {
stufeAnsicht.value = k
localStorage.setItem('stufe', String(k))
}
function handleOpenBausteineView() {
if (!selectedTopic.value) return
bausteineView.value = true
elementsView.value = false
allgemeinView.value = false
previewGuide.value = null
}
@@ -296,12 +306,22 @@ function handlePreview(guide) {
previewGuide.value = guide
elementsView.value = false
bausteineView.value = false
allgemeinView.value = false
}
function handleGeneralExam() {
if (!selectedTopic.value) return
allgemeinView.value = true
bausteineView.value = false
elementsView.value = false
previewGuide.value = null
}
function handleOpenElements() {
if (!selectedTopic.value) return
elementsView.value = true
bausteineView.value = false
allgemeinView.value = false
// Rechte Sidebar bleibt zu — sie öffnet erst beim Klick auf ein Element.
}
@@ -380,9 +400,12 @@ onMounted(async () => {
:providers="providers"
:folders="folders"
:ansichtModus="ansichtModus"
:stufeAnsicht="stufeAnsicht"
@setProvider="setProvider"
@toggleDark="toggleDark"
@setAnsicht="setAnsicht"
@setStufe="setStufe"
@generalExam="handleGeneralExam"
@select="selectTopic"
@create="createTopic"
@createThema="handleCreateThema"
@@ -413,6 +436,13 @@ onMounted(async () => {
:version="elementsVersion"
@open="handleOpenElementDetail"
/>
<GeneralExamPanel
v-else-if="selectedTopic && allgemeinView"
:topic="selectedTopic"
:provider="provider"
@progressChanged="loadStats(); loadBausteine()"
@fokus-active="fokusOffen = $event"
/>
<TopicDetail
v-else-if="selectedTopic"
:previewGuide="previewGuide"
@@ -422,6 +452,7 @@ onMounted(async () => {
:doneByFormat="doneByFormat"
:themaAbgeschlossen="!!fortschritt.abgeschlossen"
:ansichtModus="ansichtModus"
:stufeAnsicht="stufeAnsicht"
@progressChanged="loadStats(); loadBausteine()"
@setAnsicht="setAnsicht"
@open-sidebar="sidebarSticky = true"

View File

@@ -146,12 +146,19 @@ export async function deleteGuide(id, slots = false) {
await fetch(`${BASE}/guides/${id}${slots ? '?slots=1' : ''}`, { method: 'DELETE' })
}
export async function fetchGuideContent(id) {
const res = await fetch(`${BASE}/guides/${id}/content`)
export async function fetchGuideContent(id, ebene = 4) {
const res = await fetch(`${BASE}/guides/${id}/content?ebene=${ebene}`)
if (!res.ok) throw new Error(`Inhalt nicht verfügbar (${res.status})`)
return res.json()
}
// Lern-Artefakte (Karteikarten/Beispiele/Diagramme) je Thema, gruppiert nach Baustein-Norm.
export async function fetchArtefakte(topic) {
const res = await fetch(`${BASE}/bausteine/artefakte?topic=${encodeURIComponent(topic)}`)
if (!res.ok) return { artefakte: {} }
return res.json()
}
// Einen Markdown-Block on-demand gegen die Guide-Regeln prüfen (Fokus, Rechtsklick).
export async function pruefeBlock(id, { baustein, stelle, block, hinweis = '', provider }) {
const res = await fetch(`${BASE}/guides/${id}/block/pruefen`, {

View File

@@ -25,7 +25,9 @@ const emit = defineEmits(['statusChanged', 'openFokus', 'setAnsicht'])
// Lernstufen relativ zum cap (stufen.js): grün/blau/lila/gold @ 20/40/60/100 %.
// Prüfung IMMER zufällig aus 5 Formen (mit Muster); cap = 4×relevante Subbausteine.
const MODI = ['quiz_leicht', 'quiz_schwer', 'lueck_leicht', 'lueck_schwer', 'erklaeren']
// Vier Frageformen, zufällig. Kein leicht/schwer mehr — die Schwierigkeit setzt das Backend
// über das Lerner-Niveau (Adressaten-Rolle). lueckwahl = Begriff-Auswahl · lueckentext = frei.
const MODI = ['quiz', 'lueckwahl', 'lueckentext', 'erklaeren']
const st = computed(() => props.status || { gute_antworten: 0, streak: 0, cap: props.cap })
const score = computed(() => st.value.gute_antworten || 0)
const cap = computed(() => st.value.cap || props.cap)
@@ -238,20 +240,20 @@ function naechsterModus() {
// Ein Frage-Objekt eines bestimmten Modus generieren (aus gezogener Saat; Erklären hat Fallback).
function frageEinzeln(modus = naechsterModus()) {
const form = modus.startsWith('quiz') ? 'quiz' : modus.startsWith('lueck') ? 'lueckentext' : 'erklaeren'
const istSchwer = modus.endsWith('_schwer')
const form = modus === 'quiz' ? 'quiz' : modus.startsWith('lueck') ? 'lueckentext' : 'erklaeren'
const istLueckFrei = modus === 'lueckentext' // frei tippen vs. lueckwahl = Begriff aus 4
const muster = musterNehmen()
const basis = {
topic: props.topic, baustein: props.baustein, section: props.section,
section_kompakt: props.sectionKompakt, provider: props.provider,
}
if (form === 'quiz' && muster) {
return pruefeBaustein({ ...basis, aktion: 'quiz_frage', muster, schwer: istSchwer })
.then((r) => ({ form, schwer: istSchwer, frage: r.frage, optionen: r.optionen }))
return pruefeBaustein({ ...basis, aktion: 'quiz_frage', muster }) // Single-Choice, Niveau steuert
.then((r) => ({ form, schwer: false, frage: r.frage, optionen: r.optionen }))
}
if (form === 'lueckentext' && muster) {
return pruefeBaustein({ ...basis, aktion: 'lueck_frage', muster, schwer: istSchwer })
.then((r) => istSchwer
return pruefeBaustein({ ...basis, aktion: 'lueck_frage', muster, schwer: istLueckFrei })
.then((r) => istLueckFrei
? ({ form, schwer: true, frage: r.satz, satz: r.satz, loesung: r.loesung, alternativen: r.alternativen })
: ({ form, schwer: false, frage: r.satz, satz: r.satz, optionen: r.optionen }))
}

View File

@@ -11,12 +11,14 @@ const items = ref([])
const loading = ref(true)
const error = ref(null)
// Stufen-Reihenfolge + Anzeige-Label (Farbe via CSS-Klasse st-<key>)
// Lernpfad-Stufen: Reihenfolge + Label (Farbe via CSS-Klasse st-<key>).
const STUFEN = [
{ key: 'einfach', label: 'Einfach' },
{ key: 'mittel', label: 'Mittel' },
{ key: 'schwer', label: 'Schwer' },
{ key: 'anfaenger', label: 'Anfänger' },
{ key: 'fortgeschritten', label: 'Fortgeschritten' },
{ key: 'experte', label: 'Experte' },
]
// Alt-Themen tragen noch einfach/mittel/schwer → auf die neuen Keys mappen.
const ALT_STUFE = { einfach: 'anfaenger', mittel: 'fortgeschritten', schwer: 'experte' }
watch(() => props.topic, load, { immediate: true })
@@ -43,7 +45,7 @@ function relevant(b) {
// Nur nicht-leere Stufen-Gruppen je Baustein (v-if + v-for nicht auf einem Element)
function gruppen(b) {
return STUFEN
.map((st) => ({ ...st, subs: (b.subbausteine || []).filter((s) => s.stufe === st.key) }))
.map((st) => ({ ...st, subs: (b.subbausteine || []).filter((s) => (ALT_STUFE[s.stufe] || s.stufe) === st.key) }))
.filter((g) => g.subs.length)
}
@@ -185,9 +187,9 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subbausteine
border-left: 3px solid var(--st);
padding-left: 0.6rem;
}
.bk-stufe.st-einfach { --st: var(--success-border); }
.bk-stufe.st-mittel { --st: var(--warning-border); }
.bk-stufe.st-schwer { --st: var(--danger); }
.bk-stufe.st-anfaenger { --st: var(--stufe-anfaenger); }
.bk-stufe.st-fortgeschritten { --st: var(--stufe-fortgeschritten); }
.bk-stufe.st-experte { --st: var(--stufe-experte); }
.bk-stufe-label {
font-size: 0.66rem;

View File

@@ -0,0 +1,106 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { renderMarkdownInline } from '../markdown.js'
const props = defineProps({ karten: { type: Array, default: () => [] } })
const offen = ref(false)
const reihenfolge = ref([])
const pos = ref(0)
const flipped = ref(false)
function reset() {
reihenfolge.value = props.karten.map((_, i) => i)
pos.value = 0
flipped.value = false
}
watch(() => props.karten, reset, { immediate: true })
const aktuelle = computed(() => props.karten[reihenfolge.value[pos.value]] || null)
const zaehler = computed(() => `${Math.min(pos.value + 1, reihenfolge.value.length)} / ${reihenfolge.value.length}`)
function weiter(gewusst) {
if (gewusst) {
pos.value++
} else {
// „Nochmal" → Karte ans Ende der Runde schieben (leichtes Spacing).
const [k] = reihenfolge.value.splice(pos.value, 1)
reihenfolge.value.push(k)
}
if (pos.value >= reihenfolge.value.length) pos.value = 0
flipped.value = false
}
</script>
<template>
<div v-if="karten.length" class="flashcards">
<button class="art-head" @click="offen = !offen">
<span class="art-icon">🃏</span> Karteikarten
<span class="art-count">{{ karten.length }}</span>
<span class="art-toggle">{{ offen ? '▾' : '▸' }}</span>
</button>
<div v-if="offen && aktuelle" class="fc-body">
<div class="fc-card" :class="{ flipped }" @click="flipped = !flipped">
<div class="fc-zaehler">{{ zaehler }}</div>
<div v-if="!flipped" class="fc-seite">
<span class="fc-label">Frage</span>
<div class="fc-text" v-html="renderMarkdownInline(aktuelle.frage)"></div>
<span class="fc-hint">Klick zum Umdrehen</span>
</div>
<div v-else class="fc-seite">
<span class="fc-label">Antwort</span>
<div class="fc-text" v-html="renderMarkdownInline(aktuelle.antwort)"></div>
</div>
</div>
<div v-if="flipped" class="fc-aktionen">
<button class="fc-btn nochmal" @click="weiter(false)">Nochmal</button>
<button class="fc-btn gewusst" @click="weiter(true)">Gewusst</button>
</div>
</div>
</div>
</template>
<style scoped>
.flashcards { margin-top: 0.75rem; }
.art-head {
display: flex; align-items: center; gap: 8px; width: 100%;
background: none; border: none; cursor: pointer; padding: 0.35rem 0;
font-size: 0.82rem; font-weight: 600; color: var(--text-muted);
}
.art-icon { font-size: 0.95rem; }
.art-count {
background: var(--panel-soft); border: 1px solid var(--border);
border-radius: 999px; padding: 0 0.45rem; font-size: 0.72rem;
}
.art-toggle { margin-left: auto; color: var(--text-faint); }
.fc-body { margin-top: 0.5rem; }
.fc-card {
position: relative; min-height: 120px; cursor: pointer;
border: 1px solid var(--border); border-radius: 10px;
padding: 1rem 1.1rem; background: var(--panel-soft);
display: flex; align-items: center; justify-content: center; text-align: center;
transition: border-color 0.15s;
}
.fc-card.flipped { border-color: var(--accent); }
.fc-zaehler {
position: absolute; top: 6px; right: 10px;
font-size: 0.68rem; color: var(--text-faint);
}
.fc-seite { display: flex; flex-direction: column; gap: 0.4rem; align-items: center; }
.fc-label {
font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.05em;
color: var(--text-faint); font-weight: 700;
}
.fc-text { font-size: 0.98rem; line-height: 1.45; }
.fc-hint { font-size: 0.7rem; color: var(--text-faint); margin-top: 0.2rem; }
.fc-aktionen { display: flex; gap: 8px; margin-top: 0.5rem; }
.fc-btn {
flex: 1; padding: 0.5rem; border-radius: 8px; cursor: pointer;
font-size: 0.82rem; font-weight: 600; border: 1px solid var(--border-strong);
background: var(--panel);
}
.fc-btn.gewusst { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
.fc-btn.nochmal { color: var(--text-muted); }
</style>

View File

@@ -0,0 +1,120 @@
<script setup>
// Themenweite Misch-Prüfung: wählt einen Baustein (priorisiert: noch nicht auf Max), darunter
// zufällig, und nutzt dafür das normale BausteinPanel. Jeder Baustein hat seinen eigenen
// Prüfungs-Slot (pruefungCache), also bleiben die Frage-Pools getrennt. Buchung läuft je
// Baustein über die normale Prüfungs-Route. Gesamt-cap = Σ cap_final aller prüfbaren Bausteine.
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { fetchGuides, fetchGuideContent, fetchBausteinLernstand } from '../api.js'
import BausteinPanel from './BausteinPanel.vue'
const props = defineProps({
topic: { type: String, required: true },
provider: { type: String, default: 'claude' },
})
const emit = defineEmits(['progressChanged', 'fokus-active'])
const sections = ref([]) // [{titel, md, kompakt, num}] — prüfbare Sections der Vollfassung
const lernstand = ref({}) // titel -> {gute_antworten, streak, cap, cap_aktuell, freie_ebene}
const aktiv = ref(null) // aktueller Baustein-Titel
const ladefehler = ref(null)
const pruefbare = computed(() => sections.value.filter((s) => (lernstand.value[s.titel]?.cap || 0) > 0))
// Gesamt-Fortschritt: Σ min(score, cap_final) / Σ cap_final über alle prüfbaren Bausteine.
const gesamt = computed(() => {
let score = 0, cap = 0
for (const s of pruefbare.value) {
const l = lernstand.value[s.titel] || {}
score += Math.min(l.gute_antworten || 0, l.cap || 0)
cap += l.cap || 0
}
return { score, cap }
})
const aktivSection = computed(() => sections.value.find((s) => s.titel === aktiv.value) || null)
const aktivStatus = computed(() => lernstand.value[aktiv.value] || null)
// Priorität: Bausteine, die noch nicht auf cap_final sind; darunter zufällig.
function waehleBaustein() {
const offen = pruefbare.value.filter((s) => {
const l = lernstand.value[s.titel] || {}
return (l.gute_antworten || 0) < (l.cap || 0)
})
const pool = offen.length ? offen : pruefbare.value
aktiv.value = pool.length ? pool[Math.floor(Math.random() * pool.length)].titel : null
}
async function ladeLernstand() {
try {
lernstand.value = (await fetchBausteinLernstand(props.topic)).bausteine || {}
} catch { /* offline → alter Stand */ }
}
onMounted(async () => {
emit('fokus-active', true)
try {
const guides = await fetchGuides()
const g = guides
.filter((x) => x.topic === props.topic && x.format === 'Guide' && x.status === 'done')
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1))[0]
if (!g) { ladefehler.value = 'Kein fertiger Guide — erst einen Guide erstellen.'; return }
const content = await fetchGuideContent(g.id, 4) // Vollfassung (alle Subs)
sections.value = (content.chapters || [])
.flatMap((ch) => ch.sections || [])
.filter((s) => s.pruefbar !== false)
.map((s) => ({ titel: s.title, md: s.md, kompakt: s.kompakt, num: s.num }))
await ladeLernstand()
waehleBaustein()
} catch {
ladefehler.value = 'Konnte die Prüfung nicht laden.'
}
})
onUnmounted(() => emit('fokus-active', false))
async function onStatus() {
await ladeLernstand() // cap_aktuell/freie_ebene können nach einer Antwort gewachsen sein
emit('progressChanged')
}
</script>
<template>
<div class="ge-panel">
<div class="ge-head">
<h2>Allgemeine Prüfung</h2>
<span v-if="!ladefehler" class="ge-score">{{ gesamt.score }} / {{ gesamt.cap }}</span>
<button v-if="aktiv" class="ge-next" title="Anderen Baustein prüfen" @click="waehleBaustein"> Anderer Baustein</button>
</div>
<p v-if="ladefehler" class="ge-msg">{{ ladefehler }}</p>
<p v-else-if="!pruefbare.length" class="ge-msg">Noch keine prüfbaren Bausteine.</p>
<p v-else-if="!aktiv" class="ge-msg">Alles auf Max nichts mehr zu prüfen. 🎉</p>
<div v-else class="ge-body">
<div class="ge-baustein">Baustein: <strong>{{ aktiv }}</strong></div>
<BausteinPanel
:key="aktiv"
mode="full"
:topic="topic"
:baustein="aktiv"
:section="aktivSection?.md || ''"
:section-kompakt="aktivSection?.kompakt || ''"
:provider="provider"
:status="aktivStatus"
:cap="aktivStatus?.cap || 0"
@statusChanged="onStatus"
/>
</div>
</div>
</template>
<style scoped>
.ge-panel { padding: 16px 20px; max-width: 900px; margin: 0 auto; }
.ge-head { display: flex; align-items: center; gap: 14px; margin-bottom: 12px; }
.ge-head h2 { margin: 0; font-size: 1.1rem; }
.ge-score { font-variant-numeric: tabular-nums; color: var(--text-muted); font-weight: 600; }
.ge-next { margin-left: auto; padding: 5px 10px; border: 1px solid var(--border-strong); border-radius: 6px; background: var(--bg); cursor: pointer; }
.ge-next:hover { color: var(--accent-hover); }
.ge-msg { color: var(--text-muted); }
.ge-baustein { margin-bottom: 8px; color: var(--text-muted); font-size: 0.9rem; }
</style>

View File

@@ -1,11 +1,22 @@
<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 { fetchGuideContent, chatGuide, fetchBausteinLernstand, fetchArtefakte } from '../api.js'
import { renderMarkdown, renderMermaid } 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'
import FlashcardWidget from './FlashcardWidget.vue'
import WorkedExampleBlock from './WorkedExampleBlock.vue'
// Titel-Normalisierung wie backend _norm_titel (casefold ≈ toLowerCase + ß→ss) — für das
// Andocken der Artefakte (gekeyt nach baustein_norm) an die Section-Titel.
function normTitel(s) {
return (s || '').normalize('NFKC')
.replace(/[`'"<>„“”‚’«»*_]/g, '').replace(/[–—‐]/g, '-')
.replace(/\s+/g, ' ').trim().replace(/^[.:;]+|[.:;]+$/g, '').trim()
.toLowerCase().replace(/ß/g, 'ss')
}
const props = defineProps({
previewGuide: { type: Object, default: null },
@@ -15,6 +26,7 @@ const props = defineProps({
doneByFormat: { type: Object, default: () => ({}) }, // Format → fertiger Guide (Themen-bezogen)
themaAbgeschlossen: { type: Boolean, default: false },
ansichtModus: { type: String, default: 'kompakt' }, // kompakt | erklärend
stufeAnsicht: { type: Number, default: 4 }, // 1=A · 2=F · 3=E · 4=V
})
const emit = defineEmits(['progressChanged', 'setAnsicht', 'openSidebar', 'fokusActive'])
@@ -27,6 +39,11 @@ 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)
const artefakte = ref({}) // baustein_norm → {karteikarte[], beispiel[], diagramm}
function artefakteVon(title) {
return artefakte.value[normTitel(title)] || null
}
// --- 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. ---
@@ -53,15 +70,20 @@ function setupLazy() {
if (!e.isIntersecting) continue
sichtbar[Number(e.target.dataset.num)] = true
mdObserver.unobserve(e.target)
nextTick(() => renderMermaid(e.target)) // Diagramme der nun sichtbaren Section rendern
}
}, { root: scrollEl.value, rootMargin: '800px 0px' })
for (const el of scrollEl.value.querySelectorAll('.section-card')) mdObserver.observe(el)
}
watch(content, () => nextTick(setupLazy))
// Artefakte kommen asynchron nach dem Content — bereits sichtbare Diagramme nachrendern.
watch(artefakte, () => nextTick(() => renderMermaid(scrollEl.value)))
onUnmounted(() => mdObserver?.disconnect())
watch(() => props.previewGuide?.id, loadContent, { immediate: true })
// Stufen-Ansicht (E/M/S/F) gewechselt → Guide-Inhalt neu mit passendem Ebenen-Filter laden.
watch(() => props.stufeAnsicht, loadContent)
async function loadContent() {
content.value = null
@@ -72,7 +94,7 @@ async function loadContent() {
const g = props.previewGuide
if (!g || g.status !== 'done') return
try {
content.value = await fetchGuideContent(g.id)
content.value = await fetchGuideContent(g.id, props.stufeAnsicht)
} catch (e) {
console.error('Fehler beim Laden des Guides:', e)
loadError.value = 'Inhalt nicht verfügbar — die Datei fehlt. Guide neu generieren (▶).'
@@ -81,6 +103,9 @@ async function loadContent() {
try {
lernstand.value = (await fetchBausteinLernstand(g.topic)).bausteine || {}
} catch { /* offline → leer */ }
try {
artefakte.value = (await fetchArtefakte(g.topic)).artefakte || {}
} catch { artefakte.value = {} }
// Beim Öffnen zum ersten noch nicht gemeisterten prüfbaren Baustein scrollen.
await nextTick()
const ziel = bausteine.value.find((s) => istPruefbar(s) && stufeVon(s.title)?.key !== 'meister')
@@ -277,6 +302,11 @@ function extractContext() {
</h3>
<div v-if="sichtbar[s.num]" class="section-body markdown" v-html="htmlFor(s)"></div>
<div v-else class="section-body skeleton"></div>
<template v-if="sichtbar[s.num] && artefakteVon(s.title)">
<pre v-if="artefakteVon(s.title).diagramm" class="mermaid art-diagramm">{{ artefakteVon(s.title).diagramm.mermaid }}</pre>
<WorkedExampleBlock :beispiele="artefakteVon(s.title).beispiel || []" />
<FlashcardWidget :karten="artefakteVon(s.title).karteikarte || []" />
</template>
<BausteinPanel
v-if="istPruefbar(s)"
mode="trigger"
@@ -519,6 +549,19 @@ function extractContext() {
damit der IntersectionObserver die folgenden Karten gestaffelt erkennt. */
.section-body.skeleton { min-height: 160px; }
/* Diagramm-Artefakt (Mermaid): zentriert, dezent gerahmt. Vor dem Rendern zeigt
<pre> den Quelltext — danach ersetzt mermaid.run ihn durch SVG. */
.art-diagramm {
margin: 0.75rem 0;
padding: 0.75rem;
background: var(--panel-soft);
border: 1px solid var(--border);
border-radius: 10px;
text-align: center;
overflow-x: auto;
}
.art-diagramm :deep(svg) { max-width: 100%; height: auto; }
.empty-preview {
display: flex;
align-items: center;

View File

@@ -23,9 +23,10 @@ const props = defineProps({
providers: { type: Array, default: () => [] },
folders: { type: Object, default: () => ({ projekt: [], uni: [] }) },
ansichtModus: { type: String, default: 'kompakt' }, // kompakt | erklärend
stufeAnsicht: { type: Number, default: 4 }, // 1=A · 2=F · 3=E · 4=V
})
const emit = defineEmits(['select', 'create', 'createThema', 'updateQuelle', 'formatClick', 'bausteineClick', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setAnsicht', 'setProvider'])
const emit = defineEmits(['select', 'create', 'createThema', 'updateQuelle', 'formatClick', 'bausteineClick', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setAnsicht', 'setStufe', 'generalExam', 'setProvider'])
// Accordion: höchstens ein Panel offen. IDs: 'bausteine', 'fmt-<Format>', 'topic-<Name>'.
const openPanel = ref(null)
@@ -54,8 +55,14 @@ const trackerItems = computed(() => {
const formats = [
{ key: 'Guide', label: 'Guide' },
{ key: 'FullGuide', label: 'FullGuide' },
{ key: 'Rest', label: 'Rest' },
]
// Stufen-Ansichten des EINEN Guides (gefiltert nach Subbaustein-Ebene).
const STUFEN_ANSICHT = [
{ k: 1, label: 'A', titel: 'Anfänger' },
{ k: 2, label: 'F', titel: 'Anfänger + Fortgeschritten' },
{ k: 3, label: 'E', titel: 'bis Experte' },
{ k: 4, label: 'V', titel: 'Vollständig (inkl. Rand)' },
]
const bausteineState = computed(() => {
@@ -317,6 +324,11 @@ function saveQuelle() {
</svg>
</button>
</div>
<div v-if="selectedTopic" class="stufe-toggle" title="Tiefe der Guide-Ansicht">
<button v-for="s in STUFEN_ANSICHT" :key="s.k"
:class="{ active: stufeAnsicht === s.k }" :title="s.titel"
@click="emit('setStufe', s.k)">{{ s.label }}</button>
</div>
<button class="new-topic-toggle" :class="{ active: dlg }" title="Thema erstellen" @click="toggleErstellen">+</button>
</div>
@@ -462,6 +474,11 @@ function saveQuelle() {
<button class="format-error-x" title="Ausblenden" @click="dismissError(f.key)">×</button>
</div>
</div>
<div class="format-row ord-pruefung">
<button class="format-name elements-btn" @click="emit('generalExam')">
<span class="format-label">Allgemeine Prüfung</span>
</button>
</div>
<div class="format-row ord-elemente">
<button class="format-name elements-btn" @click="emit('openElements')">
<span class="format-label">Elemente</span>
@@ -607,6 +624,21 @@ function saveQuelle() {
.new-topic .ansicht-toggle button:hover { color: var(--accent-hover); }
.new-topic .ansicht-toggle button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.ansicht-toggle svg { fill: currentColor; display: block; }
.stufe-toggle { display: inline-flex; margin-left: 4px; }
.new-topic .stufe-toggle button {
padding: 4px 6px;
background: var(--bg);
color: var(--text-muted);
border: 1px solid var(--border-strong);
font-size: 11px;
font-weight: 600;
min-width: 20px;
}
.new-topic .stufe-toggle button:first-child { border-radius: 6px 0 0 6px; }
.new-topic .stufe-toggle button:last-child { border-radius: 0 6px 6px 0; }
.new-topic .stufe-toggle button:not(:first-child) { border-left: none; }
.new-topic .stufe-toggle button:hover { color: var(--accent-hover); }
.new-topic .stufe-toggle button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.stats-bar {
display: flex;
@@ -800,10 +832,14 @@ function saveQuelle() {
order: 2;
}
.ord-elemente {
.ord-pruefung {
order: 4;
}
.ord-elemente {
order: 5;
}
.elements-btn {
cursor: pointer;
color: var(--text);

View File

@@ -0,0 +1,67 @@
<script setup>
import { ref } from 'vue'
import { renderMarkdownInline } from '../markdown.js'
defineProps({ beispiele: { type: Array, default: () => [] } })
const offen = ref(false)
</script>
<template>
<div v-if="beispiele.length" class="worked">
<button class="art-head" @click="offen = !offen">
<span class="art-icon">📝</span> Beispiele
<span class="art-count">{{ beispiele.length }}</span>
<span class="art-toggle">{{ offen ? '▾' : '▸' }}</span>
</button>
<div v-if="offen" class="we-body">
<div v-for="(b, i) in beispiele" :key="i" class="we-card">
<div v-if="b.subbaustein" class="we-sub">{{ b.subbaustein }}</div>
<div class="we-problem" v-html="renderMarkdownInline(b.problem)"></div>
<ol class="we-schritte">
<li v-for="(s, j) in b.schritte" :key="j" v-html="renderMarkdownInline(s)"></li>
</ol>
<div v-if="b.ergebnis" class="we-ergebnis">
<span class="we-label">Ergebnis</span>
<span v-html="renderMarkdownInline(b.ergebnis)"></span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.worked { margin-top: 0.5rem; }
.art-head {
display: flex; align-items: center; gap: 8px; width: 100%;
background: none; border: none; cursor: pointer; padding: 0.35rem 0;
font-size: 0.82rem; font-weight: 600; color: var(--text-muted);
}
.art-icon { font-size: 0.95rem; }
.art-count {
background: var(--panel-soft); border: 1px solid var(--border);
border-radius: 999px; padding: 0 0.45rem; font-size: 0.72rem;
}
.art-toggle { margin-left: auto; color: var(--text-faint); }
.we-body { margin-top: 0.4rem; display: flex; flex-direction: column; gap: 0.6rem; }
.we-card {
border: 1px solid var(--border); border-left: 3px solid var(--accent);
border-radius: 8px; padding: 0.7rem 0.9rem; background: var(--panel-soft);
font-size: 0.92rem; line-height: 1.5;
}
.we-sub {
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--text-faint); font-weight: 700; margin-bottom: 0.3rem;
}
.we-problem { font-weight: 600; margin-bottom: 0.4rem; }
.we-schritte { margin: 0 0 0.4rem 1.1rem; padding: 0; }
.we-schritte li { margin: 0.2rem 0; }
.we-ergebnis {
display: flex; gap: 6px; align-items: baseline;
padding-top: 0.35rem; border-top: 1px dashed var(--border);
}
.we-label {
font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.05em;
color: var(--success); font-weight: 700;
}
</style>

View File

@@ -6,6 +6,17 @@ import katex from 'katex'
import 'katex/dist/katex.min.css'
import DOMPurify from 'dompurify'
// Mermaid lazy laden (schwere Lib): erst beim ersten Diagramm, hält das Initial-Bundle klein.
// startOnLoad:false → wir rendern gezielt; securityLevel strict: kein HTML/JS in Graphen.
let _mermaid = null
async function getMermaid() {
if (!_mermaid) {
_mermaid = (await import('mermaid')).default
_mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'default', flowchart: { htmlLabels: false } })
}
return _mermaid
}
marked.use(markedHighlight({
langPrefix: 'hljs language-',
highlight(code, lang) {
@@ -49,7 +60,33 @@ const inlineMath = {
renderer(token) { return renderTex(token.text, false) },
}
marked.use({ extensions: [blockMath, inlineMath] })
// ```mermaid → <pre class="mermaid">…</pre> (Quelltext escaped; mermaid.run liest den textContent).
const mermaidBlock = {
name: 'mermaidBlock',
level: 'block',
start(src) { const i = src.indexOf('```mermaid'); return i < 0 ? undefined : i },
tokenizer(src) {
const m = /^```mermaid[^\n]*\n([\s\S]+?)\n```/.exec(src)
if (m) return { type: 'mermaidBlock', raw: m[0], text: m[1] }
},
renderer(token) {
const esc = token.text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
return `<pre class="mermaid">${esc}</pre>`
},
}
marked.use({ extensions: [blockMath, inlineMath, mermaidBlock] })
// Mermaid-Knoten in einem Container rendern (nach v-html). Idempotent: bereits
// verarbeitete (data-processed) überspringt mermaid.run; defekte Graphen bleiben als Text.
export async function renderMermaid(root) {
const nodes = root?.querySelectorAll?.('pre.mermaid:not([data-processed="true"])')
if (!nodes || !nodes.length) return
try {
const m = await getMermaid()
await m.run({ nodes })
} catch { /* defektes Diagramm → Quelltext bleibt sichtbar */ }
}
// Rohes HTML im Markdown (z. B. <p>, <img> ohne Backticks aus Agenten-Output)
// als Text anzeigen statt rendern — sonst verschluckt der Browser den Inhalt.

View File

@@ -1,5 +1,5 @@
// Lernstufen je Baustein — identisch zum Backend (lernen.py STUFEN).
// Floor in % des cap (cap = clamp(4×relevante Subbausteine, 10, 50)).
// Floor in % des cap_final (cap_final = alle Subbausteine × 25).
// grün=Anfänger 20% · blau=Fortgeschritten 40% · lila=Experte 60% · gold=Meister 100%.
export const STUFEN = [
{ key: 'anfaenger', label: 'Anfänger', kurz: '✓', floor: 0.2, farbe: 'var(--stufe-anfaenger)' },
@@ -25,11 +25,11 @@ export function naechste(score, cap) {
return null
}
// Fehler-Strafe-Anzeige nach Fortschritt: 020%→0 · 2140%→1 · 4160%→−2 · 61100%→−3.
// Fehler-Strafe-Anzeige nach Fortschritt (gegen cap_aktuell): ≤25%→5 · ≤50%→10 · ≤75%→−15 · >75%→−20.
export function malusRegel(score, cap) {
const pct = cap ? score / cap : 0
if (pct <= 0.2) return '0'
if (pct <= 0.4) return '1'
if (pct <= 0.6) return '2'
return '3'
if (pct <= 0.25) return '5'
if (pct <= 0.5) return '10'
if (pct <= 0.75) return '15'
return '20'
}