This commit is contained in:
team3
2026-07-03 11:45:27 +02:00
parent 285317927d
commit abcadd145d
44 changed files with 1909 additions and 292 deletions

View File

@@ -1,12 +1,13 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { fetchGuides, fetchTopics, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBlocksStatus, fetchActiveBlocks, createBlocks as apiCreateBausteine, resetBlocksStage as apiResetBlocksStage, addBlocksResearch as apiAddResearch, requeueBlocksDead as apiRequeueDead, resetGuideBoard as apiResetGuideBoard, restartBlocksCard as apiRestartBlocksCard, resetGuideCard as apiResetGuideCard, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchGuideLocks, fetchFolders, updateSource as apiUpdateQuelle } from './api.js'
import { fetchGuides, fetchTopics, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBlocksStatus, fetchActiveBlocks, createBlocks as apiCreateBausteine, resetBlocksStage as apiResetBlocksStage, addBlocksResearch as apiAddResearch, requeueBlocksDead as apiRequeueDead, resetGuideBoard as apiResetGuideBoard, restartBlocksCard as apiRestartBlocksCard, resetGuideCard as apiResetGuideCard, removeGuideFormat as apiRemoveGuideFormat, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchGuideLocks, fetchFolders, updateSource as apiUpdateQuelle } from './api.js'
import { usePolling } from './composables/usePolling.js'
import TopicSidebar from './components/TopicSidebar.vue'
import TopicDetail from './components/TopicDetail.vue'
import BlocksOverview from './components/BlocksOverview.vue'
import GenerationView from './components/GenerationView.vue'
import GeneralExamPanel from './components/GeneralExamPanel.vue'
import PracticePanel from './components/PracticePanel.vue'
const guides = ref([])
const backendTopics = ref([])
@@ -26,10 +27,11 @@ const activeBlocks = ref([])
const provider = ref(localStorage.getItem('provider') || 'claude')
const providers = ref([])
const folders = ref({ projekt: [], uni: [] }) // folders for the sources picker
const mainView = ref('blocks') // blocks | generation | general | detail — exclusive main-area view
const mainView = ref('blocks') // blocks | generation | general | practice | detail — exclusive main-area view
const guideBoardFormat = ref('Guide')
const viewMode = ref('compact') // compact | erklärend — per topic, default compact
const levelView = ref(Number(localStorage.getItem('level')) || 4) // 1=A · 2=F · 3=E · 4=V (levels view)
const _storedLevel = localStorage.getItem('level')
const levelView = ref(_storedLevel === 'auto' || !_storedLevel ? 'auto' : Number(_storedLevel) || 'auto') // 'auto' | 1-4
const stats = ref(null)
const progress = ref({})
const locks = ref({}) // lock reasons per format (backend = single rule source)
@@ -326,6 +328,17 @@ function handleOpenGeneration() {
previewGuide.value = null
}
async function handleRemoveGuideFormat(format) {
uiError.value = null
try {
await apiRemoveGuideFormat(selectedTopic.value, format)
} catch (e) {
uiError.value = e.message
return
}
await loadGuides()
}
async function handleRestartCard(cardId) {
uiError.value = null
try {
@@ -370,8 +383,20 @@ function handleGeneralExam() {
previewGuide.value = null
}
function handlePractice() {
if (!selectedTopic.value) return
mainView.value = 'practice'
previewGuide.value = null
}
async function handleDeleteGuide(guideId, slots = false) {
await deleteGuide(guideId, slots)
uiError.value = null
try {
await deleteGuide(guideId, slots)
} catch (e) {
uiError.value = e.message
return
}
if (previewGuide.value?.id === guideId) {
previewGuide.value = null
}
@@ -444,6 +469,7 @@ onMounted(async () => {
@setAnsicht="setView"
@setStufe="setLevel"
@generalExam="handleGeneralExam"
@practice="handlePractice"
@select="selectTopic"
@createThema="handleCreateTopic"
@updateSource="handleUpdateSource"
@@ -488,7 +514,7 @@ onMounted(async () => {
@startGuide="handleFormatClick"
@resetGuideStage="handleGuideBoardReset"
@preview="handleGuideBoardPreview"
@deleteGuide="handleDeleteGuide"
@removeFormat="handleRemoveGuideFormat"
@restartCard="handleRestartCard"
@resetGuideCard="handleResetGuideCard"
/>
@@ -499,6 +525,11 @@ onMounted(async () => {
@progressChanged="loadStats(); loadBlocks()"
@fokus-active="focusOpen = $event"
/>
<PracticePanel
v-else-if="selectedTopic && mainView === 'practice'"
:key="selectedTopic"
:topic="selectedTopic"
/>
<TopicDetail
v-else-if="selectedTopic"
:previewGuide="previewGuide"

View File

@@ -87,6 +87,15 @@ export async function restartBlocksCard(topic, cardId) {
return jsonOrThrow(res)
}
export async function removeGuideFormat(topic, format) {
const res = await fetch(`${BASE}/guides/board/remove`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, format }),
})
return jsonOrThrow(res)
}
export async function resetGuideCard(topic, format, blockNorm, abStage) {
const res = await fetch(`${BASE}/guides/board/card-reset`, {
method: 'POST',
@@ -193,6 +202,11 @@ export async function updateSource(topic, { type, ort = '', spec = '' }) {
return jsonOrThrow(res)
}
export async function fetchBlocksCompleteness(topic) {
const res = await fetch(`${BASE}/blocks/completeness?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res)
}
export async function fetchBlocksOverview(topic) {
const res = await fetch(`${BASE}/blocks/overview?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res)
@@ -212,11 +226,19 @@ export async function fetchGuideContent(id, level = 4) {
return res.json()
}
// Lern-Artefakte (Flashcards/Examples/Diagramme) je Thema, gruppiert nach Block-Norm.
export async function fetchArtefakte(topic) {
const res = await fetch(`${BASE}/blocks/artefakte?topic=${encodeURIComponent(topic)}`)
if (!res.ok) return { artefakte: {} }
return res.json()
// Übungspool: fällige + neue Flashcards des Themas (Leitner, ein Stapel).
export async function fetchPracticeDeck(topic) {
const res = await fetch(`${BASE}/practice/deck?topic=${encodeURIComponent(topic)}`)
return jsonOrThrow(res)
}
// Leitner-Schritt buchen (correct = „Gewusst").
export async function answerPracticeCard({ topic, block_norm, sub_norm, correct }) {
const res = await fetch(`${BASE}/practice/answer`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, block_norm, sub_norm, correct }),
})
return jsonOrThrow(res)
}
// Einen Markdown-Block on-demand gegen die Guide-Rules prüfen (Fokus, Rechtsklick).

View File

@@ -1,7 +1,5 @@
<script setup>
import BlockPanel from './BlockPanel.vue'
import FlashcardWidget from './FlashcardWidget.vue'
import WorkedExampleBlock from './WorkedExampleBlock.vue'
import { renderMarkdown, renderBlocks } from '../markdown.js'
import { stufeFuer, LEVELS } from '../levels.js'
import { pruefeBlock, uebernehmeBlock, resetBlockProgress } from '../api.js'
@@ -10,7 +8,6 @@ import { useConfirm } from '../composables/useConfirm.js'
const props = defineProps({
block: { type: Object, required: true }, // { title, md, num }
artefakte: { type: Object, default: null }, // { flashcard[], example[], diagramm } for this block
topic: { type: String, required: true },
provider: { type: String, default: 'claude' },
status: { type: Object, default: null },
@@ -199,10 +196,6 @@ watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''}
</div>
</template>
</div>
<template v-if="artefakte">
<WorkedExampleBlock :examples="artefakte.example || []" />
<FlashcardWidget :cards="artefakte.flashcard || []" />
</template>
</div>
<div v-if="menu.show" class="menu-overlay" @click="closeMenu" @contextmenu.prevent="closeMenu">
<div class="block-menu" :style="{ top: menu.y + 'px', left: menu.x + 'px' }" @click.stop>

View File

@@ -1,6 +1,6 @@
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { fetchBlocksOverview } from '../api.js'
import { fetchBlocksOverview, fetchBlocksCompleteness } from '../api.js'
const props = defineProps({
topic: { type: String, required: true },
@@ -14,6 +14,16 @@ const emit = defineEmits(['close', 'openGeneration'])
const items = ref([])
const loading = ref(true)
const error = ref(null)
const comp = ref(null) // Vollständigkeits-Beleg (nur wenn ready)
const compOpen = ref(false)
async function loadCompleteness() {
if (!props.ready) { comp.value = null; return }
try {
comp.value = await fetchBlocksCompleteness(props.topic)
} catch { comp.value = null }
}
watch(() => [props.topic, props.ready, props.generating], loadCompleteness, { immediate: true })
// Während einer Generierung wächst das Grid live nach (leichter Overview-Poll,
// das Kanban-Board selbst lebt in der Generierungs-View).
@@ -77,6 +87,26 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
Noch keine Bausteine zur Generierung
</button>
<section v-if="comp" class="bk-panel" :class="{ ok: comp.vollstaendig }">
<button class="bk-panel-row" @click="compOpen = !compOpen">
<span class="bk-panel-status">{{ comp.vollstaendig ? '✓ Zerlegung vollständig' : '○ Zerlegung unvollständig' }}</span>
<span class="bk-panel-stat">{{ comp.bloecke }} Blöcke</span>
<span class="bk-panel-stat">{{ comp.subs }} Subbausteine</span>
<span v-if="comp.ziele_total" class="bk-panel-stat">Lernziele {{ comp.ziele_covered }}/{{ comp.ziele_total }}</span>
<span class="bk-panel-stat">{{ comp.frage_bloecke }}/{{ comp.bloecke }} mit Prüfungsfragen</span>
<span class="bk-panel-stat">{{ comp.lernkarten }} Lernartefakte</span>
<span v-if="comp.dead" class="bk-panel-stat warn">{{ comp.dead }} dead</span>
<span class="bk-panel-toggle">{{ compOpen ? '▴' : '▾' }}</span>
</button>
<div v-if="compOpen" class="bk-panel-detail">
<span>{{ comp.verworfen }} Kandidaten geprüft verworfen</span>
<span>{{ comp.zusammengelegt }} zusammengelegt (Dubletten/Umbrellas)</span>
<span>{{ comp.degradiert_geprueft }} Fragmente degradiert (Panel-geprüft)</span>
<span v-if="comp.panel_gerettet">{{ comp.panel_gerettet }} vom Panel gerettet</span>
<span v-if="comp.lauf_minuten">Lauf: {{ comp.lauf_minuten }} min</span>
</div>
</section>
<div v-if="loading" class="bk-empty-state">Loading</div>
<div v-else-if="error && !generating" class="bk-empty-state">{{ error }}</div>
<div v-else-if="!items.length" class="bk-empty-state">No blocks yet.</div>
@@ -176,6 +206,43 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
.bk-banner.idle { border-color: var(--border-strong); color: var(--text-muted); }
.bk-banner:hover { background: var(--panel-soft); }
.bk-panel {
margin: 0.85rem 2rem 0;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--panel);
}
.bk-panel.ok { border-color: var(--level-beginner); }
.bk-panel-row {
width: 100%;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 1.1rem;
padding: 0.55rem 0.9rem;
border: none;
background: none;
color: var(--text);
font-size: 0.82rem;
cursor: pointer;
text-align: left;
}
.bk-panel-status { font-weight: 700; }
.bk-panel.ok .bk-panel-status { color: var(--level-beginner); }
.bk-panel-stat { color: var(--text-muted); }
.bk-panel-stat.warn { color: var(--danger); font-weight: 600; }
.bk-panel-toggle { margin-left: auto; color: var(--text-faint); }
.bk-panel-detail {
display: flex;
flex-wrap: wrap;
gap: 0.3rem 1.1rem;
padding: 0 0.9rem 0.6rem;
font-size: 0.78rem;
color: var(--text-faint);
border-top: 1px dashed var(--border);
padding-top: 0.5rem;
}
.bk-empty-state {
flex: 1;
display: flex;

View File

@@ -1,79 +1,37 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { ref, watch } from 'vue'
import { renderMarkdownInline } from '../markdown.js'
const props = defineProps({ cards: { type: Array, default: () => [] } })
// Reine Flip-Karte: der Übungspool (PracticePanel) steuert Stapel und Leitner —
// die Karte zeigt nur Frage/Antwort und meldet die Bewertung nach oben.
const props = defineProps({ card: { type: Object, required: true } }) // { question, answer }
const emit = defineEmits(['answer']) // answer(correct: boolean)
const open = ref(false)
const order = ref([])
const pos = ref(0)
const flipped = ref(false)
function reset() {
order.value = props.cards.map((_, i) => i)
pos.value = 0
flipped.value = false
}
watch(() => props.cards, reset, { immediate: true })
const current = computed(() => props.cards[order.value[pos.value]] || null)
const counter = computed(() => `${Math.min(pos.value + 1, order.value.length)} / ${order.value.length}`)
function next(known) {
if (known) {
pos.value++
} else {
// "Again" → push the card to the end of the round (light spacing).
const [k] = order.value.splice(pos.value, 1)
order.value.push(k)
}
if (pos.value >= order.value.length) pos.value = 0
flipped.value = false
}
watch(() => props.card, () => { flipped.value = false })
</script>
<template>
<div v-if="cards.length" class="flashcards">
<button class="art-head" @click="open = !open">
<span class="art-icon">🃏</span> Flashcards
<span class="art-count">{{ cards.length }}</span>
<span class="art-toggle">{{ open ? '▾' : '▸' }}</span>
</button>
<div v-if="open && current" class="fc-body">
<div class="fc-card" :class="{ flipped }" @click="flipped = !flipped">
<div class="fc-zaehler">{{ counter }}</div>
<div v-if="!flipped" class="fc-seite">
<span class="fc-label">Question</span>
<div class="fc-text" v-html="renderMarkdownInline(current.question)"></div>
<span class="fc-hint">Click to flip</span>
</div>
<div v-else class="fc-seite">
<span class="fc-label">Answer</span>
<div class="fc-text" v-html="renderMarkdownInline(current.answer)"></div>
</div>
<div class="fc-body">
<div class="fc-card" :class="{ flipped }" @click="flipped = !flipped">
<div v-if="!flipped" class="fc-seite">
<span class="fc-label">Frage</span>
<div class="fc-text" v-html="renderMarkdownInline(card.question)"></div>
<span class="fc-hint">Klicken zum Umdrehen</span>
</div>
<div v-if="flipped" class="fc-aktionen">
<button class="fc-btn nochmal" @click="next(false)">Again</button>
<button class="fc-btn gewusst" @click="next(true)">Knew it</button>
<div v-else class="fc-seite">
<span class="fc-label">Antwort</span>
<div class="fc-text" v-html="renderMarkdownInline(card.answer)"></div>
</div>
</div>
<div v-if="flipped" class="fc-aktionen">
<button class="fc-btn nochmal" @click="emit('answer', false)">Nochmal</button>
<button class="fc-btn gewusst" @click="emit('answer', true)">Gewusst</button>
</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;
@@ -83,10 +41,6 @@ function next(known) {
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;

View File

@@ -13,7 +13,7 @@ const props = defineProps({
guideFormat: { type: String, default: 'Guide' },
})
const emit = defineEmits(['close', 'resetStage', 'restartAll', 'continueAll', 'addResearch',
'requeueDead', 'removeAll', 'cancel', 'cancelGuide', 'startGuide', 'resetGuideStage', 'preview', 'deleteGuide', 'restartCard', 'resetGuideCard'])
'requeueDead', 'removeAll', 'cancel', 'cancelGuide', 'startGuide', 'resetGuideStage', 'preview', 'removeFormat', 'restartCard', 'resetGuideCard'])
// ── Blocks-Pipeline (Poll 1.2s solange generiert) ──────────────────────────────
const board = ref(null)
@@ -65,17 +65,22 @@ function restartCard() {
const k = selCard.value
selCard.value = null
confirm.value = null
emit('restartCard', k.card_id)
later(() => emit('restartCard', k.card_id))
}
function arm(action, fn) {
if (confirm.value === action) { confirm.value = null; fn() }
else confirm.value = action
}
function later(fn) { // Aktion emitten, Board kurz danach neu laden (kein generating-Poll aktiv)
fn()
setTimeout(pollBoard, 600)
}
function resetHere(restart) {
const s = sel.value
sel.value = null
confirm.value = null
emit('resetStage', { board: s.board, stage: s.key, restart })
later(() => emit('resetStage', { board: s.board, stage: s.key, restart }))
}
</script>
@@ -99,7 +104,7 @@ function resetHere(restart) {
v-if="ready || partial"
class="gen-act danger"
:class="{ armed: confirm === 'remove' }"
@click="arm('remove', () => emit('removeAll'))"
@click="arm('remove', () => later(() => emit('removeAll')))"
>{{ confirm === 'remove' ? 'Sure?' : 'Remove' }}</button>
</div>
<div v-else class="gen-actions">
@@ -110,7 +115,7 @@ function resetHere(restart) {
v-if="dead.length"
class="gen-act"
:title="dead.map((d) => d.title + ': ' + d.error).join('\n')"
@click="emit('requeueDead')"
@click="later(() => emit('requeueDead'))"
> {{ dead.length }} dead</button>
</div>
@@ -158,7 +163,7 @@ function resetHere(restart) {
@startGuide="(p) => emit('startGuide', p)"
@resetStage="(p) => emit('resetGuideStage', p)"
@preview="emit('preview')"
@deleteGuide="(id) => emit('deleteGuide', id)"
@removeFormat="(f) => emit('removeFormat', f)"
@resetCard="(p) => emit('resetGuideCard', p)"
/>
</section>

View File

@@ -89,7 +89,7 @@ function resetHere() {
<template v-else>
<button class="gb-act play" @click="emit('startGuide', { format, abStep: null }); startPoll()">{{ total && done < total ? 'Fortsetzen' : total ? 'Neu generieren' : 'Generieren' }}</button>
<button v-if="done === total && total" class="gb-act" @click="emit('preview')">Guide öffnen</button>
<button v-if="total" class="gb-act danger" :class="{ armed: confirm === 'delete' }" @click="arm('delete', () => emit('deleteGuide', board?.guide_id))">{{ confirm === 'delete' ? 'Sure?' : 'Remove' }}</button>
<button v-if="total" class="gb-act danger" :class="{ armed: confirm === 'delete' }" @click="arm('delete', () => { emit('removeFormat', format); setTimeout(poll, 600) })">{{ confirm === 'delete' ? 'Sure?' : 'Remove' }}</button>
</template>
</div>
</div>

View File

@@ -0,0 +1,114 @@
<script setup>
// Übungspool: EIN Leitner-Stapel pro Thema (fällige Karten zuerst, dann neue).
// Der Server wählt und ordnet die Karten (Entscheidungs-Entlastung); „Nochmal"
// schiebt die Karte ans Rundenende, gebucht wird jede Antwort sofort.
import { ref, computed, onMounted } from 'vue'
import { fetchPracticeDeck, answerPracticeCard } from '../api.js'
import FlashcardWidget from './FlashcardWidget.vue'
const props = defineProps({
topic: { type: String, required: true },
})
const deck = ref([]) // Karten in Übungsreihenfolge
const counts = ref(null) // {due, new, new_total, gesperrt}
const nextDueAt = ref(null)
const loadError = ref(null)
const loading = ref(true)
const erledigt = ref(0)
const current = computed(() => deck.value[0] || null)
const offen = computed(() => deck.value.length)
async function loadDeck() {
loading.value = true
loadError.value = null
erledigt.value = 0
try {
const d = await fetchPracticeDeck(props.topic)
deck.value = d.cards || []
counts.value = d.counts || null
nextDueAt.value = d.next_due_at
} catch (e) {
loadError.value = e.message || 'Übungsstapel nicht ladbar.'
} finally {
loading.value = false
}
}
onMounted(loadDeck)
async function onAnswer(correct) {
const card = current.value
if (!card) return
try {
await answerPracticeCard({
topic: props.topic, block_norm: card.block_norm,
sub_norm: card.sub_norm, correct,
})
} catch { /* Buchung offline fehlgeschlagen — Durchgang läuft lokal weiter */ }
deck.value.shift()
if (correct) {
erledigt.value += 1
} else {
deck.value.push(card) // Rundenende: Box 1 ist sofort wieder fällig
}
}
function naechsteFaelligkeit() {
if (!nextDueAt.value) return null
const d = new Date(nextDueAt.value)
return d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' })
}
</script>
<template>
<div class="pr-panel">
<div class="pr-head">
<h2>Üben</h2>
<span v-if="counts" class="pr-counts">
{{ counts.due }} fällig · {{ counts.new }} neu<template v-if="counts.new_total > counts.new"> (von {{ counts.new_total }})</template>
</span>
<span v-if="offen" class="pr-rest">{{ offen }} übrig</span>
</div>
<p v-if="loadError" class="pr-msg">{{ loadError }}</p>
<p v-else-if="loading" class="pr-msg">Lade Stapel</p>
<div v-else-if="current" class="pr-body">
<div class="pr-kontext">{{ current.block }} · {{ current.subblock }}</div>
<FlashcardWidget :card="current" @answer="onAnswer" />
</div>
<div v-else class="pr-done">
<p class="pr-done-title">Alles erledigt </p>
<p v-if="erledigt" class="pr-msg">{{ erledigt }} Karten in dieser Runde.</p>
<p v-if="nextDueAt" class="pr-msg">Nächste Karten fällig: {{ naechsteFaelligkeit() }}</p>
<button
v-if="counts && counts.new_total > counts.new"
class="pr-mehr" @click="loadDeck"
>Weitere neue Karten üben</button>
<p v-if="counts && counts.gesperrt" class="pr-msg pr-faint">
{{ counts.gesperrt }} Karten schalten sich über Block-Prüfungen frei.
</p>
</div>
</div>
</template>
<style scoped>
.pr-panel { padding: 16px 20px; max-width: 720px; margin: 0 auto; }
.pr-head { display: flex; align-items: center; gap: 14px; margin-bottom: 12px; }
.pr-head h2 { margin: 0; font-size: 1.1rem; }
.pr-counts { color: var(--text-muted); font-weight: 600; font-variant-numeric: tabular-nums; }
.pr-rest { margin-left: auto; color: var(--text-faint); font-size: 0.85rem; font-variant-numeric: tabular-nums; }
.pr-msg { color: var(--text-muted); }
.pr-faint { color: var(--text-faint); font-size: 0.85rem; }
.pr-kontext { margin-bottom: 6px; color: var(--text-muted); font-size: 0.88rem; }
.pr-done { text-align: center; padding: 2.5rem 0; }
.pr-done-title { font-size: 1.15rem; font-weight: 700; margin-bottom: 0.6rem; }
.pr-mehr {
margin-top: 0.6rem; padding: 7px 14px; border: 1px solid var(--border-strong);
border-radius: 8px; background: var(--bg); cursor: pointer; font-weight: 600;
}
.pr-mehr:hover { color: var(--accent-hover); }
</style>

View File

@@ -1,22 +1,11 @@
<script setup>
import { computed, reactive, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { fetchGuideContent, chatGuide, fetchBlockLearnState, fetchArtefakte } from '../api.js'
import { fetchGuideContent, chatGuide, fetchBlockLearnState } from '../api.js'
import { renderMarkdown } from '../markdown.js'
import { stufeFuer, schwelle } from '../levels.js'
import { stufeFuer, schwelle, SUB_RANK, VIEW_KURZ, VIEW_FARBE, viewLevelFuer } from '../levels.js'
import { useChat } from '../composables/useChat.js'
import BlockPanel from './BlockPanel.vue'
import BlockFocus from './BlockFocus.vue'
import FlashcardWidget from './FlashcardWidget.vue'
import WorkedExampleBlock from './WorkedExampleBlock.vue'
// Title normalization like backend _norm_title (casefold ≈ toLowerCase + ß→ss) — for
// attaching the artifacts (keyed by block_norm) to the section titles.
function normTitle(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 },
@@ -25,7 +14,7 @@ const props = defineProps({
doneByFormat: { type: Object, default: () => ({}) }, // format → finished guide (topic-related)
themaAbgeschlossen: { type: Boolean, default: false },
ansichtModus: { type: String, default: 'compact' }, // compact | erklärend
stufeAnsicht: { type: Number, default: 4 }, // 1=A · 2=F · 3=E · 4=V
stufeAnsicht: { type: [Number, String], default: 'auto' }, // 'auto' | 1=A · 2=F · 3=E · 4=V
})
const emit = defineEmits(['progressChanged', 'setAnsicht', 'openSidebar', 'fokusActive'])
@@ -38,11 +27,6 @@ const content = ref(null)
const loadError = ref(null)
const scrollEl = ref(null)
const learnstate = ref({}) // exam state per block title — BEFORE the immediate watch (loadContent uses it)
const artifacts = ref({}) // block_norm → {flashcard[], example[], diagramm}
function artifactsFor(title) {
return artifacts.value[normTitle(title)] || null
}
// --- Lazy render + markdown cache: parse only visible sections, each only once.
// Fixes the freeze on open (160× marked/highlight.js) and re-parse on every update. ---
@@ -50,13 +34,37 @@ const mdCache = new Map() // `${mode}:${num}` → html
const visible = reactive({}) // num → true (stays true once ever visible)
let mdObserver = null
// Auto: Ansichtsstufe des Blocks folgt dem Prüfungs-Score (Erreicht + 1); Override = global fest.
function viewLevelFor(title) {
if (props.stufeAnsicht !== 'auto') return Number(props.stufeAnsicht)
const l = learnstate.value[title]
return viewLevelFuer(l?.good_answers || 0, l?.cap || 10)
}
function htmlFor(s) {
const key = `${props.ansichtModus}:${s.num}`
const lvl = viewLevelFor(s.title)
const key = `${props.ansichtModus}:${s.num}:${lvl}:${props.stufeAnsicht}`
let h = mdCache.get(key)
if (h === undefined) {
h = renderMarkdown(props.ansichtModus === 'compact' ? (s.compact || s.md) : s.md)
mdCache.set(key, h)
if (h !== undefined) return h
const compact = props.ansichtModus === 'compact'
if (!s.subs || !s.subs.length) { // Legacy-Abschnitt ohne Marker → ungefiltert
h = renderMarkdown(compact ? (s.compact || s.md) : s.md)
} else {
const anchor = compact ? (s.anker_compact || '') : (s.anchor || '')
const parts = [renderMarkdown(anchor)]
for (const sub of s.subs) {
const rank = SUB_RANK[sub.level] || 1
if (rank > lvl) continue
const body = renderMarkdown(compact ? (sub.compact || sub.md) : (sub.md || sub.compact))
if (props.stufeAnsicht === 'auto' && lvl > 1 && rank === lvl) {
parts.push(`<div class="sub-neu" style="--neu-farbe:${VIEW_FARBE[rank]}"><span class="sub-neu-badge" title="Neu ab Stufe ${VIEW_KURZ[rank]}">${VIEW_KURZ[rank]}</span>${body}</div>`)
} else {
parts.push(body)
}
}
h = parts.join('')
}
mdCache.set(key, h)
return h
}
@@ -79,7 +87,7 @@ onUnmounted(() => mdObserver?.disconnect())
watch(() => props.previewGuide?.id, loadContent, { immediate: true })
// Level view (E/M/S/F) changed → reload guide content with the matching depth filter.
watch(() => props.stufeAnsicht, loadContent)
watch(() => props.stufeAnsicht, () => mdCache.clear())
async function loadContent() {
content.value = null
@@ -90,7 +98,7 @@ async function loadContent() {
const g = props.previewGuide
if (!g || g.status !== 'done') return
try {
content.value = await fetchGuideContent(g.id, props.stufeAnsicht)
content.value = await fetchGuideContent(g.id, 4) // Stufen-Filterung passiert lokal (auto/Override)
} catch (e) {
console.error('Error loading guide:', e)
loadError.value = 'Content unavailable — the file is missing. Regenerate the guide (▶).'
@@ -99,9 +107,6 @@ async function loadContent() {
try {
learnstate.value = (await fetchBlockLearnState(g.topic)).blocks || {}
} catch { /* offline → empty */ }
try {
artifacts.value = (await fetchArtefakte(g.topic)).artefakte || {}
} catch { artifacts.value = {} }
// On open, scroll to the first not-yet-mastered checkable block.
await nextTick()
const target = blocks.value.find((s) => isCheckable(s) && levelOf(s.title)?.key !== 'master')
@@ -297,10 +302,6 @@ function extractContext() {
</h3>
<div v-if="visible[s.num]" class="section-body markdown" v-html="htmlFor(s)"></div>
<div v-else class="section-body skeleton"></div>
<template v-if="visible[s.num] && artifactsFor(s.title)">
<WorkedExampleBlock :examples="artifactsFor(s.title).example || []" />
<FlashcardWidget :cards="artifactsFor(s.title).flashcard || []" />
</template>
<BlockPanel
v-if="isCheckable(s)"
mode="trigger"
@@ -327,7 +328,6 @@ function extractContext() {
<BlockFocus
v-if="focusBlock"
:block="focusBlock"
:artefakte="artifactsFor(focusBlock.title)"
:topic="previewGuide.topic"
:guide-id="previewGuide.id"
:provider="provider"
@@ -766,4 +766,25 @@ function extractContext() {
.chat-input button.cancel {
background: var(--danger);
}
/* Neu freigeschaltete Subbausteine (Auto-Stufe): dezenter Rand + Stufen-Badge */
.sub-neu {
position: relative;
border-left: 3px solid var(--neu-farbe);
padding-left: 0.75rem;
margin: 0.5rem 0;
border-radius: 2px;
}
.sub-neu-badge {
position: absolute;
top: 0.1rem;
right: 0;
font-size: 0.62rem;
font-weight: 700;
color: var(--neu-farbe);
border: 1px solid var(--neu-farbe);
border-radius: 4px;
padding: 0 4px;
opacity: 0.8;
}
</style>

View File

@@ -25,7 +25,7 @@ const props = defineProps({
stufeAnsicht: { type: Number, default: 4 }, // 1=A · 2=F · 3=E · 4=V
})
const emit = defineEmits(['select', 'createThema', 'updateSource', 'bausteineClick', 'deleteTopic', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openGuideBoard', 'openGeneration', 'togglePin', 'sidebarLeave', 'toggleDark', 'setAnsicht', 'setStufe', 'generalExam', 'setProvider'])
const emit = defineEmits(['select', 'createThema', 'updateSource', 'bausteineClick', 'deleteTopic', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openGuideBoard', 'openGeneration', 'togglePin', 'sidebarLeave', 'toggleDark', 'setAnsicht', 'setStufe', 'generalExam', 'practice', 'setProvider'])
// Accordion: at most one panel open. IDs: 'blocks', 'fmt-<Format>', 'topic-<Name>'.
const openPanel = ref(null)
@@ -58,6 +58,7 @@ const formats = [
// Level views of the SINGLE guide (filtered by subblock depth).
const LEVEL_VIEWS = [
{ k: 'auto', label: '⟳', title: 'Auto — Stufe folgt deinem Prüfungs-Score je Block' },
{ k: 1, label: 'A', title: 'Beginner' },
{ k: 2, label: 'F', title: 'Beginner + Advanced' },
{ k: 3, label: 'E', title: 'up to Expert' },
@@ -337,6 +338,11 @@ function saveSource() {
<span class="format-label">General Exam</span>
</button>
</div>
<div class="format-row ord-practice">
<button class="format-name elements-btn" @click="emit('practice')">
<span class="format-label">Üben</span>
</button>
</div>
</div>
<ul class="topic-list">
@@ -665,6 +671,10 @@ function saveSource() {
order: 4;
}
.ord-practice {
order: 5;
}
.elements-btn {
cursor: pointer;
color: var(--text);

View File

@@ -1,67 +0,0 @@
<script setup>
import { ref } from 'vue'
import { renderMarkdownInline } from '../markdown.js'
defineProps({ examples: { type: Array, default: () => [] } })
const open = ref(false)
</script>
<template>
<div v-if="examples.length" class="worked">
<button class="art-head" @click="open = !open">
<span class="art-icon">📝</span> Examples
<span class="art-count">{{ examples.length }}</span>
<span class="art-toggle">{{ open ? '▾' : '▸' }}</span>
</button>
<div v-if="open" class="we-body">
<div v-for="(b, i) in examples" :key="i" class="we-card">
<div v-if="b.subblock" class="we-sub">{{ b.subblock }}</div>
<div class="we-problem" v-html="renderMarkdownInline(b.problem)"></div>
<ol class="we-steps">
<li v-for="(s, j) in b.steps" :key="j" v-html="renderMarkdownInline(s)"></li>
</ol>
<div v-if="b.result" class="we-result">
<span class="we-label">Result</span>
<span v-html="renderMarkdownInline(b.result)"></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-steps { margin: 0 0 0.4rem 1.1rem; padding: 0; }
.we-steps li { margin: 0.2rem 0; }
.we-result {
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

@@ -33,3 +33,19 @@ export function malusRegel(score, cap) {
if (pct <= 0.75) return '15'
return '20'
}
// Sub-level tag (from the guide markers) → view level 1..4 (A/F/E/V).
export const SUB_RANK = { beginner: 1, advanced: 2, expert: 3, peripheral: 4, einfach: 1, mittel: 2, schwer: 3 }
export const VIEW_KURZ = { 1: 'A', 2: 'F', 3: 'E', 4: 'V' }
export const VIEW_FARBE = {
1: 'var(--level-beginner)', 2: 'var(--level-advanced)',
3: 'var(--level-expert)', 4: 'var(--level-master)',
}
// Auto view level per block: reached learning level + 1 (nothing reached → A).
// beginner → F unlocked, advanced → E, expert/master → V.
export function viewLevelFuer(score, cap) {
const s = stufeFuer(score, cap)
if (!s) return 1
return Math.min(4, LEVELS.findIndex((l) => l.key === s.key) + 2)
}