This commit is contained in:
team3
2026-06-18 16:45:33 +02:00
parent 9c0f622e0c
commit d067cb8b54
27 changed files with 329 additions and 327 deletions

View File

@@ -26,6 +26,7 @@ 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 ansichtModus = ref('kompakt') // kompakt | erklärend — je Thema, Default kompakt
const stats = ref(null)
const fortschritt = ref({})
const locks = ref({}) // Sperr-Gründe pro Format (Backend = einzige Regel-Quelle)
@@ -201,6 +202,7 @@ function selectTopic(topic) {
elementsView.value = false
bausteineView.value = false
elementOpenId.value = null
ansichtModus.value = localStorage.getItem('ansicht_' + topic) || 'kompakt'
localStorage.setItem('lastTopic', topic)
loadBausteine()
nextTick(autoPreview)
@@ -268,6 +270,15 @@ async function handleUpdateQuelle({ topic, type, ort, spec }) {
await loadBausteine() // Schritt-Anzeige folgt ggf. der neuen Quelle (z.B. Link → „Quelle laden")
}
function setAnsicht(modus) {
ansichtModus.value = modus
if (selectedTopic.value) localStorage.setItem('ansicht_' + selectedTopic.value, modus)
}
function toggleAnsicht() {
setAnsicht(ansichtModus.value === 'kompakt' ? 'erklärend' : 'kompakt')
}
function handleOpenBausteineView() {
if (!selectedTopic.value) return
bausteineView.value = true
@@ -384,8 +395,10 @@ onMounted(async () => {
:provider="provider"
:providers="providers"
:folders="folders"
:ansichtModus="ansichtModus"
@setProvider="setProvider"
@toggleDark="toggleDark"
@toggleAnsicht="toggleAnsicht"
@select="selectTopic"
@create="createTopic"
@createThema="handleCreateThema"
@@ -424,7 +437,9 @@ onMounted(async () => {
:elementsOpen="elementsOpen"
:doneByFormat="doneByFormat"
:themaAbgeschlossen="!!fortschritt.abgeschlossen"
:ansichtModus="ansichtModus"
@progressChanged="loadStats(); loadBausteine()"
@setAnsicht="setAnsicht"
/>
<div v-else class="empty-main">
<p>Thema in der Sidebar anlegen oder auswählen.</p>

View File

@@ -59,47 +59,31 @@ export async function deleteBausteine(topic) {
await fetch(`${BASE}/bausteine?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' })
}
// --- Baustein-Lernen: Vertiefung, Chat, Prüfung ---
// --- Baustein-Lernen: Chat, Prüfung ---
export async function fetchBausteinLernstand(topic) {
const res = await fetch(`${BASE}/bausteine/lernstand?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res)
}
export async function fetchVertiefung(topic, baustein, art = 'vertiefung') {
const res = await fetch(
`${BASE}/bausteine/vertiefung?topic=${encodeURIComponent(topic)}&baustein=${encodeURIComponent(baustein)}&art=${art}`
)
return jsonOrThrow(res)
}
export async function createVertiefung({ topic, baustein, section, art = 'vertiefung', provider }) {
const res = await fetch(`${BASE}/bausteine/vertiefung`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, baustein, section, art, provider }),
})
return jsonOrThrow(res)
}
export async function chatBaustein({ topic, baustein, section, messages, provider }) {
export async function chatBaustein({ topic, baustein, section, section_kompakt = '', messages, provider }) {
const res = await fetch(`${BASE}/bausteine/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, baustein, section, messages, provider }),
body: JSON.stringify({ topic, baustein, section, section_kompakt, messages, provider }),
})
return jsonOrThrow(res)
}
export async function pruefeBaustein({
topic, baustein, section, provider,
topic, baustein, section, section_kompakt = '', provider,
aktion = 'frage', frage = '', letzte_bewertung = '', score_vor_frage = 0, streak = 0,
tier2 = false, tier3 = false, messages = [], gruendlich = false,
}) {
const res = await fetch(`${BASE}/bausteine/pruefung`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, baustein, section, aktion, frage, letzte_bewertung, score_vor_frage, streak, tier2, tier3, messages, provider, gruendlich }),
body: JSON.stringify({ topic, baustein, section, section_kompakt, aktion, frage, letzte_bewertung, score_vor_frage, streak, tier2, tier3, messages, provider, gruendlich }),
})
return jsonOrThrow(res)
}

View File

@@ -10,6 +10,7 @@ const props = defineProps({
tier2: { type: Boolean, default: false },
tier3: { type: Boolean, default: false },
tab: { type: String, default: 'pruefung' },
ansicht: { type: String, default: 'kompakt' }, // kompakt | erklärend — linke Guide-Spalte
hasPrev: { type: Boolean, default: false },
hasNext: { type: Boolean, default: false },
fortschritt: { type: Object, default: () => ({ total: 0, absolviert: 0, verstanden: 0, gemeistert: 0 }) },
@@ -17,7 +18,7 @@ const props = defineProps({
import { computed, onMounted, onUnmounted, ref } from 'vue'
const emit = defineEmits(['close', 'prev', 'next', 'statusChanged'])
const emit = defineEmits(['close', 'prev', 'next', 'statusChanged', 'setAnsicht'])
const guideEl = ref(null) // linke Guide-Spalte (Scroll-Ziel für ALT+↑/↓)
const rightEl = ref(null) // rechte Spalte (Prüfungs-Panel mit Eingabefeld)
@@ -88,7 +89,7 @@ const standFarbe = computed(() => {
</div>
<div class="fokus-body">
<div ref="guideEl" class="fokus-col left">
<div class="markdown" v-html="renderMarkdown(baustein.md)"></div>
<div class="markdown" v-html="renderMarkdown(ansicht === 'kompakt' ? (baustein.kompakt || baustein.md) : baustein.md)"></div>
</div>
<div ref="rightEl" class="fokus-col right">
<BausteinPanel
@@ -98,10 +99,13 @@ const standFarbe = computed(() => {
:topic="topic"
:baustein="baustein.title"
:section="baustein.md"
:section-kompakt="baustein.kompakt || ''"
:provider="provider"
:status="status"
:tier2="tier2"
:tier3="tier3"
:ansicht="ansicht"
@set-ansicht="$emit('setAnsicht', $event)"
@status-changed="$emit('statusChanged', $event)"
/>
</div>

View File

@@ -1,6 +1,6 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { chatBaustein, createVertiefung, fetchVertiefung, pruefeBaustein } from '../api.js'
import { chatBaustein, pruefeBaustein } from '../api.js'
import { usePruefSlot } from '../pruefungCache.js'
import { renderMarkdown } from '../markdown.js'
import { useChat, istUnten } from '../composables/useChat.js'
@@ -8,77 +8,38 @@ import { useChat, istUnten } from '../composables/useChat.js'
const props = defineProps({
topic: { type: String, required: true },
baustein: { type: String, required: true },
section: { type: String, default: '' },
section: { type: String, default: '' }, // ausführliche Fassung
sectionKompakt: { type: String, default: '' }, // kompakte Fassung (Merksätze) — Prüfungs-/Chat-Kontext
provider: { type: String, default: 'claude' },
status: { type: Object, default: null }, // {gute_antworten, absolviert, verstanden, gemeistert, vertiefung}
status: { type: Object, default: null }, // {gute_antworten, streak, absolviert, verstanden, gemeistert}
tier2: { type: Boolean, default: false }, // Tier 2 frei (ganzer Guide absolviert)
tier3: { type: Boolean, default: false }, // Meisterpfad frei (ganzer Guide verstanden)
// 'trigger' (Liste): nur Tab-Leiste, Klick öffnet den Vollbild-Fokus.
// 'full' (Fokus): Tabs + Panel-Inhalt wie gehabt.
mode: { type: String, default: 'trigger' },
initialTab: { type: String, default: null },
ansicht: { type: String, default: 'kompakt' }, // kompakt | erklärend — steuert die linke Guide-Spalte
})
const emit = defineEmits(['statusChanged', 'openFokus'])
const emit = defineEmits(['statusChanged', 'openFokus', 'setAnsicht'])
const NOETIG = 3 // absolviert
const MAX = 10 // verstanden
const MEISTERN = 25 // gemeistert (Maximum)
const st = computed(() => props.status || { gute_antworten: 0, streak: 0, absolviert: false, verstanden: false, gemeistert: false, vertiefung: false, deepdive: false })
const st = computed(() => props.status || { gute_antworten: 0, streak: 0, absolviert: false, verstanden: false, gemeistert: false })
// --- Toggle-Bereich ---
const activeTab = ref(props.mode === 'full' ? props.initialTab : null) // null | 'vertiefung' | 'deepdive' | 'chat' | 'pruefung'
const activeTab = ref(props.mode === 'full' ? props.initialTab : null) // null | 'chat' | 'pruefung'
// Klick auf einen Tab: in der Liste den Fokus öffnen, im Fokus den Tab umschalten.
function tabKlick(tab) {
if (props.mode === 'trigger') { emit('openFokus', tab); return }
toggle(tab)
}
function toggle(tab) {
activeTab.value = activeTab.value === tab ? null : tab
if (activeTab.value === 'vertiefung' || activeTab.value === 'deepdive') openText(activeTab.value)
}
// --- Vertiefung (gleicher Stoff, mehr) + Amateur (gleicher Stoff, für Einsteiger), beide persistiert (intern art 'deepdive') ---
const texte = ref({
vertiefung: { md: null, loading: false, error: '' },
deepdive: { md: null, loading: false, error: '' },
})
async function openText(art) {
const t = texte.value[art]
if (t.md !== null || t.loading || !st.value[art]) return
t.loading = true
t.error = ''
try {
t.md = (await fetchVertiefung(props.topic, props.baustein, art)).md
} catch (e) {
t.error = e.message
} finally {
t.loading = false
}
}
async function generateText(art) {
const t = texte.value[art]
t.loading = true
t.error = ''
try {
t.md = (await createVertiefung({
topic: props.topic, baustein: props.baustein, section: props.section, art, provider: props.provider,
})).md
emit('statusChanged', { ...st.value, [art]: true })
} catch (e) {
t.error = e.message
} finally {
t.loading = false
}
}
// --- Bausteinchat (flüchtig) ---
const chat = useChat((msgs) => chatBaustein({
topic: props.topic, baustein: props.baustein, section: props.section,
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
messages: msgs, provider: props.provider,
}))
@@ -131,7 +92,7 @@ async function pruefSenden(payload, onOk) {
pruefScroll()
try {
const res = await pruefeBaustein({
topic: props.topic, baustein: props.baustein, section: props.section,
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
provider: props.provider, messages: pruefDialog(), ...payload,
})
if (run !== pruefRun) return
@@ -175,7 +136,7 @@ function istBekannt(text) {
async function frageEinzeln() {
const res = await pruefeBaustein({
topic: props.topic, baustein: props.baustein, section: props.section,
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
provider: props.provider, aktion: 'frage', messages: pruefDialog(),
})
return res.frage
@@ -275,7 +236,7 @@ async function verdictPruefen(gruendlich = false) {
if (gruendlich) pruefLoading.value = true
try {
const res = await pruefeBaustein({
topic: props.topic, baustein: props.baustein, section: props.section,
topic: props.topic, baustein: props.baustein, section: props.section, section_kompakt: props.sectionKompakt,
provider: props.provider, messages: pruefDialog(), ...antwortPayload(), aktion: 'antwort_pruefen', gruendlich,
})
if (meine !== pruefenRun) return // eine neuere Bewertung läuft → diese verwerfen
@@ -336,8 +297,6 @@ onMounted(() => {
if (props.mode !== 'full') return // Trigger-Modus (Liste): nur Tab-Leiste, keine Prüfungs-/Text-Logik
window.addEventListener('keydown', onWindowKey)
window.addEventListener('keydown', onPruefKey)
// Direkt im Fokus geöffneter Vertiefung-/Amateur-Tab: Text laden (sonst nur via toggle).
if (activeTab.value === 'vertiefung' || activeTab.value === 'deepdive') openText(activeTab.value)
})
onUnmounted(() => {
window.removeEventListener('keydown', onWindowKey)
@@ -370,12 +329,11 @@ function onPruefKey(e) {
<template>
<div class="bp">
<div class="bp-toggles">
<button :class="{ active: activeTab === 'vertiefung' }" @click="tabKlick('vertiefung')">
Vertiefung
</button>
<button :class="{ active: activeTab === 'deepdive' }" @click="tabKlick('deepdive')">
Amateur
</button>
<!-- Ansicht-Umschalter (nur Vollbild): steuert die linke Guide-Spalte, links vor den Tabs -->
<div v-if="mode === 'full'" class="bp-ansicht">
<button :class="{ active: ansicht === 'kompakt' }" title="Merksätze" @click="emit('setAnsicht', 'kompakt')">Kompakt</button>
<button :class="{ active: ansicht === 'erklärend' }" title="Ausführliche Erklärung" @click="emit('setAnsicht', 'erklärend')">Erklärend</button>
</div>
<button :class="{ active: activeTab === 'chat' }" @click="tabKlick('chat')">
Chat
</button>
@@ -391,24 +349,8 @@ function onPruefKey(e) {
</div>
<div v-if="mode === 'full' && activeTab" class="bp-panel">
<!-- Vertiefung (gleicher Stoff, mehr) / Amateur (gleicher Stoff, für Einsteiger) -->
<div v-if="activeTab === 'vertiefung' || activeTab === 'deepdive'">
<p v-if="texte[activeTab].loading" class="bp-hint">{{ texte[activeTab].md === null ? 'Generiere' : 'Lade' }}</p>
<template v-else-if="texte[activeTab].md">
<div class="markdown" v-html="renderMarkdown(texte[activeTab].md)"></div>
<button class="bp-action" @click="generateText(activeTab)">Neu generieren</button>
</template>
<template v-else>
<p class="bp-hint">{{ activeTab === 'deepdive' ? 'Noch keine Amateur-Fassung zu diesem Baustein.' : 'Noch keine Vertiefung zu diesem Baustein.' }}</p>
<button class="bp-action" @click="generateText(activeTab)">
{{ activeTab === 'deepdive' ? 'Amateur generieren' : 'Vertiefung generieren' }}
</button>
</template>
<p v-if="texte[activeTab].error" class="bp-error">{{ texte[activeTab].error }}</p>
</div>
<!-- Bausteinchat -->
<div v-else-if="activeTab === 'chat'">
<div v-if="activeTab === 'chat'">
<div :ref="chat.messagesEl" class="bp-messages" @scroll="chat.onScroll">
<p v-if="!chat.messages.value.length" class="bp-hint">Frag etwas zu diesem Baustein. Der Verlauf wird nicht gespeichert.</p>
<template v-for="(m, i) in chat.messages.value" :key="i">
@@ -504,6 +446,21 @@ function onPruefKey(e) {
.bp-toggles button:hover { border-color: var(--border-strong); color: var(--text); }
.bp-toggles button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
/* Ansicht-Umschalter (Kompakt/Erklärend) — segmentiert, links vor den Tabs */
.bp-ansicht { display: inline-flex; margin-right: 0.5rem; }
.bp-ansicht button {
padding: 0.25rem 0.6rem;
font-size: 0.75rem;
border: 1px solid var(--border);
background: var(--panel-soft);
color: var(--text-muted);
cursor: pointer;
}
.bp-ansicht button:first-child { border-radius: 999px 0 0 999px; }
.bp-ansicht button:last-child { border-radius: 0 999px 999px 0; border-left: none; }
.bp-ansicht button:hover { color: var(--text); }
.bp-ansicht button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.bp-chip {
font-size: 0.7rem;
padding: 0 0.35rem;

View File

@@ -33,6 +33,13 @@ async function load() {
}
}
// Baustein relevant = hat ≥1 relevanten Subbaustein (gleiche Regel wie ProGuide).
// Ohne Relevanz-Daten (Alt-Themen) nicht dimmen.
function relevant(b) {
const mitRelevanz = (b.subbausteine || []).filter((s) => s.relevanz)
return !mitRelevanz.length || mitRelevanz.some((s) => s.relevanz === 'relevant')
}
// Nur nicht-leere Stufen-Gruppen je Baustein (v-if + v-for nicht auf einem Element)
function gruppen(b) {
return STUFEN
@@ -58,7 +65,13 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subbausteine
<div v-else-if="!items.length" class="bk-empty-state">Noch keine Bausteine.</div>
<div v-else class="bk-grid">
<article v-for="b in items" :key="b.num" class="bk-card">
<article
v-for="b in items"
:key="b.num"
class="bk-card"
:class="{ 'bk-irrelevant': !relevant(b) }"
:title="relevant(b) ? null : 'Nicht relevant — nur im FullGuide'"
>
<h3 class="bk-title"><span class="bk-num">{{ b.num }}</span>{{ b.titel }}</h3>
<p v-if="b.beschreibung" class="bk-desc">{{ b.beschreibung }}</p>
<div v-if="b.subbausteine && b.subbausteine.length" class="bk-stufen">
@@ -136,6 +149,8 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subbausteine
border-radius: 10px;
padding: 1rem 1.1rem;
}
/* Nicht relevante Bausteine (kein relevanter Subbaustein) gedimmt */
.bk-card.bk-irrelevant { opacity: 0.5; }
.bk-title {
display: flex;
@@ -182,18 +197,17 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subbausteine
color: var(--st);
}
.bk-stufe ul {
list-style: none;
list-style: disc;
margin: 0.25rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
padding-left: 1.15rem;
}
.bk-stufe li {
font-size: 0.85rem;
color: var(--text);
line-height: 1.35;
margin-bottom: 2px;
}
.bk-stufe li::marker { color: var(--text-faint); }
.bk-stufe li.rand { color: var(--text-faint); }
.rand-tag {
margin-left: 0.4rem;

View File

@@ -13,9 +13,10 @@ const props = defineProps({
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'])
const emit = defineEmits(['progressChanged', 'setAnsicht'])
const isOnePager = computed(() => props.previewGuide?.format === 'OnePager')
@@ -26,6 +27,7 @@ const CH_COLORS = ['#3b82f6', '#8b5cf6', '#14b8a6', '#f59e0b', '#22c55e', '#6366
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 })
@@ -49,9 +51,7 @@ async function loadContent() {
}
}
// --- Baustein-Lernen: Prüfungs-Stand pro Baustein-Titel ---
const lernstand = ref({})
// --- 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 }
@@ -199,7 +199,7 @@ function extractContext() {
<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(s.md)"></div>
<div class="section-body markdown" v-html="renderMarkdown(!isOnePager && ansichtModus === 'kompakt' ? (s.kompakt || s.md) : s.md)"></div>
<BausteinPanel
v-if="!isOnePager"
mode="trigger"
@@ -234,11 +234,13 @@ function extractContext() {
:tier2="tier2"
:tier3="tier3"
: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)"
/>

View File

@@ -21,9 +21,10 @@ const props = defineProps({
provider: { type: String, default: 'claude' },
providers: { type: Array, default: () => [] },
folders: { type: Object, default: () => ({ projekt: [], uni: [] }) },
ansichtModus: { type: String, default: 'kompakt' }, // kompakt | erklärend
})
const emit = defineEmits(['select', 'create', 'createThema', 'updateQuelle', 'formatClick', 'bausteineClick', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setProvider'])
const emit = defineEmits(['select', 'create', 'createThema', 'updateQuelle', 'formatClick', 'bausteineClick', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'toggleAnsicht', 'setProvider'])
// Accordion: höchstens ein Panel offen. IDs: 'bausteine', 'fmt-<Format>', 'topic-<Name>'.
const openPanel = ref(null)
@@ -282,6 +283,13 @@ function saveQuelle() {
:title="dark ? 'Hellmodus' : 'Dunkelmodus'"
@click="emit('toggleDark')"
>{{ dark ? '☀' : '🌙' }}</button>
<button
v-if="selectedTopic"
class="theme-btn ansicht-btn"
:class="{ erkl: ansichtModus === 'erklärend' }"
:title="ansichtModus === 'kompakt' ? 'Ansicht: kompakt — klicken für erklärend' : 'Ansicht: erklärend — klicken für kompakt'"
@click="emit('toggleAnsicht')"
>{{ ansichtModus === 'kompakt' ? 'K' : 'E' }}</button>
<button class="new-topic-toggle" :class="{ active: dlg }" title="Thema erstellen" @click="toggleErstellen">+</button>
</div>
@@ -555,6 +563,9 @@ function saveQuelle() {
border-color: var(--accent-border);
}
.new-topic .ansicht-btn { font-weight: 700; }
.new-topic .ansicht-btn.erkl { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.stats-bar {
display: flex;
padding: 0.5rem 0.75rem;