304 lines
13 KiB
Vue
304 lines
13 KiB
Vue
<script setup>
|
||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||
import { api } from '../api.js'
|
||
import { render, lesestat } from '../markdown.js'
|
||
|
||
const props = defineProps({ daten: Object, topic: String })
|
||
const emit = defineEmits(['schliessen'])
|
||
|
||
const SCHWELLE = 10 // Anzeige; die Wahrheit entscheidet der Server (PRUEFUNG_SCHWELLE)
|
||
|
||
const wurzel = ref(null)
|
||
const pos = ref(0)
|
||
const fertig = ref(new Set()) // baustein-ids mit bestandener Prüfung
|
||
const xpMap = ref({}) // baustein-id → Saldo
|
||
|
||
// Screen-Liste: je Kapitel ein Intro, je Baustein Fundament → Abruf → Prüfung.
|
||
const screens = computed(() => {
|
||
const out = []
|
||
const kaps = props.daten?.kapitel || []
|
||
kaps.forEach((kap, ki) => {
|
||
out.push({ typ: 'intro', kap, nr: ki + 1, von: kaps.length })
|
||
for (const s of kap.sections) {
|
||
out.push({ typ: 'fundament', baustein: s.baustein, titel: s.titel, lang: s.lang })
|
||
out.push({ typ: 'abruf', baustein: s.baustein, titel: s.titel })
|
||
out.push({ typ: 'pruefung', baustein: s.baustein, titel: s.titel })
|
||
}
|
||
})
|
||
out.push({ typ: 'ende' })
|
||
return out
|
||
})
|
||
const screen = computed(() => screens.value[pos.value] || { typ: 'ende' })
|
||
|
||
const bausteinAnzahl = computed(() =>
|
||
(props.daten?.kapitel || []).reduce((a, k) => a + k.sections.length, 0))
|
||
const fortschrittText = computed(() => `${fertig.value.size} / ${bausteinAnzahl.value} Bausteine`)
|
||
|
||
// ── Stats (wie Guide.vue: 100 Wörter/min + 5 s je Display-Formel) ──────────────
|
||
function zeit(min) { return !min ? '~0 min' : min < 1 ? '<1 min' : `~${Math.round(min)} min` }
|
||
function kapStat(kap) {
|
||
let w = 0, f = 0
|
||
for (const s of kap.sections) { const t = lesestat(s.lang || ''); w += t.woerter; f += t.displayFormeln }
|
||
return { woerter: w, minuten: w / 100 + f * 5 / 60 }
|
||
}
|
||
|
||
const htmlCache = new Map()
|
||
function fundamentHtml(s) {
|
||
if (!htmlCache.has(s.baustein)) htmlCache.set(s.baustein, render(s.lang))
|
||
return htmlCache.get(s.baustein)
|
||
}
|
||
|
||
// ── Navigation + Prüfungs-Gate ─────────────────────────────────────────────────
|
||
// Lesen + Abruf sind frei. Nur die Prüfung sperrt: weiter erst ab Saldo ≥ Schwelle.
|
||
// 0 Panels sperren NICHT (leerer Baustein darf den Guide nie blockieren).
|
||
const gesperrt = computed(() =>
|
||
screen.value.typ === 'pruefung' && panels.value.length > 0
|
||
&& !fertig.value.has(screen.value.baustein))
|
||
function vor() { if (!gesperrt.value && pos.value < screens.value.length - 1) pos.value++ }
|
||
function zurueck() { if (pos.value > 0) pos.value-- }
|
||
const buehne = ref(null)
|
||
function taste(e) {
|
||
if (e.key === 'ArrowRight') vor()
|
||
else if (e.key === 'ArrowLeft') zurueck()
|
||
// Pfeil hoch/runter scrollen die Bühne — der innere Scroller hat nie Fokus,
|
||
// ohne das hier bewegen die Tasten also gar nichts
|
||
else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||
e.preventDefault()
|
||
buehne.value?.scrollBy({ top: e.key === 'ArrowDown' ? 140 : -140, behavior: 'smooth' })
|
||
}
|
||
// Abruf: Leertaste deckt auf, 1 = falsch, 2 = richtig
|
||
else if (screen.value.typ === 'abruf' && stapel.value.length) {
|
||
if (e.key === ' ') { e.preventDefault(); zeigeAntwort.value = true }
|
||
else if (zeigeAntwort.value && e.key === '1') antworten(false)
|
||
else if (zeigeAntwort.value && e.key === '2') antworten(true)
|
||
}
|
||
// Prüfung: 1–4 toggeln die Aussagen, Leertaste prüft / blättert zum nächsten Panel
|
||
else if (screen.value.typ === 'pruefung' && panel.value.length) {
|
||
if (e.key === ' ') {
|
||
e.preventDefault()
|
||
aufloesung.value ? naechstesPanel() : pruefen()
|
||
} else if (['1', '2', '3', '4'].includes(e.key)) {
|
||
const x = panel.value[Number(e.key) - 1]
|
||
if (x) toggle(x.id)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Wisch-Navigation (mobil): klar horizontal + kurz genug = blättern.
|
||
// Vertikales Scrollen und Text-Markieren bleiben unberührt (dy-Vergleich).
|
||
let wisch = null
|
||
function wischStart(e) {
|
||
// Start auf horizontal scrollbarem Element (breite Formel/Tabelle):
|
||
// der Wisch gehört dem Element, nicht der Seiten-Navigation
|
||
for (let el = e.target; el && el !== e.currentTarget; el = el.parentElement) {
|
||
if (el.scrollWidth > el.clientWidth + 1) { wisch = null; return }
|
||
}
|
||
const t = e.touches[0]
|
||
wisch = { x: t.clientX, y: t.clientY }
|
||
}
|
||
function wischEnde(e) {
|
||
if (!wisch) return
|
||
const t = e.changedTouches[0]
|
||
const dx = t.clientX - wisch.x
|
||
const dy = t.clientY - wisch.y
|
||
wisch = null
|
||
if (Math.abs(dx) < 60 || Math.abs(dx) < 2 * Math.abs(dy)) return
|
||
if (dx < 0) vor()
|
||
else zurueck()
|
||
}
|
||
|
||
// ── Abrufprüfung (Karteikarten) ────────────────────────────────────────────────
|
||
// Persistenz über Leitner (DB): richtig beantwortete Karten sind bis zur
|
||
// Wiedervorlage nicht fällig und erscheinen beim nächsten Besuch nicht mehr.
|
||
const stapel = ref([])
|
||
const alleKarten = ref([]) // gefüllt, wenn alles absolviert → „Alle erneut üben"
|
||
const zeigeAntwort = ref(false)
|
||
const sende = ref(false)
|
||
|
||
async function abrufLaden(bausteinId) {
|
||
zeigeAntwort.value = false
|
||
alleKarten.value = []
|
||
stapel.value = await api.bausteinKarten(props.topic, bausteinId)
|
||
if (!stapel.value.length)
|
||
alleKarten.value = await api.bausteinKarten(props.topic, bausteinId, true)
|
||
}
|
||
function nochmalAlle() {
|
||
stapel.value = alleKarten.value
|
||
alleKarten.value = []
|
||
zeigeAntwort.value = false
|
||
}
|
||
async function antworten(richtig) {
|
||
if (sende.value || !stapel.value.length) return
|
||
sende.value = true
|
||
try {
|
||
const karte = stapel.value[0]
|
||
await api.antwort(karte.id, richtig)
|
||
stapel.value = richtig ? stapel.value.slice(1) : [...stapel.value.slice(1), karte]
|
||
zeigeAntwort.value = false
|
||
if (!stapel.value.length) await abrufLaden(screen.value.baustein)
|
||
} finally { sende.value = false }
|
||
}
|
||
|
||
// ── Prüfung (4er-Panels, exakte Teilmenge ankreuzen) ───────────────────────────
|
||
const panels = ref([])
|
||
const panelIdx = ref(0)
|
||
const auswahl = ref(new Set())
|
||
const aufloesung = ref(null) // nach dem Prüfen: [{id, wahr, erklaerung}]
|
||
const warRichtig = ref(false)
|
||
const saldo = ref(0)
|
||
const panel = computed(() => panels.value[panelIdx.value]?.panel || [])
|
||
|
||
async function pruefungLaden(bausteinId) {
|
||
panels.value = await api.pruefung(props.topic, bausteinId)
|
||
panelIdx.value = 0; auswahl.value = new Set(); aufloesung.value = null
|
||
saldo.value = xpMap.value[bausteinId] || 0
|
||
if (!panels.value.length && !fertig.value.has(bausteinId)) {
|
||
// keine Aussagen generiert → Baustein nicht blockieren, als erledigt werten
|
||
fertig.value = new Set([...fertig.value, bausteinId])
|
||
await api.bausteinFertig(props.topic, bausteinId).catch(() => {})
|
||
}
|
||
}
|
||
function toggle(id) {
|
||
if (aufloesung.value) return
|
||
const s = new Set(auswahl.value)
|
||
s.has(id) ? s.delete(id) : s.add(id)
|
||
auswahl.value = s
|
||
}
|
||
async function pruefen() {
|
||
if (aufloesung.value || sende.value) return
|
||
sende.value = true
|
||
try {
|
||
const ids = panel.value.map(x => x.id)
|
||
const r = await api.pruefungAntwort(props.topic, ids, [...auswahl.value])
|
||
aufloesung.value = r.aufloesung
|
||
warRichtig.value = r.richtig
|
||
saldo.value = r.xp
|
||
xpMap.value = { ...xpMap.value, [screen.value.baustein]: r.xp }
|
||
if (r.fertig) fertig.value = new Set([...fertig.value, screen.value.baustein])
|
||
} finally { sende.value = false }
|
||
}
|
||
async function naechstesPanel() {
|
||
aufloesung.value = null; auswahl.value = new Set()
|
||
if (panelIdx.value < panels.value.length - 1) panelIdx.value++
|
||
else await pruefungLaden(screen.value.baustein) // neu mischen, Nachschub
|
||
}
|
||
function urteil(id) {
|
||
const a = (aufloesung.value || []).find(x => x.id === id)
|
||
return a ? a.wahr : null
|
||
}
|
||
|
||
watch(screen, (s) => {
|
||
if (s.typ === 'abruf') abrufLaden(s.baustein)
|
||
else if (s.typ === 'pruefung') pruefungLaden(s.baustein)
|
||
})
|
||
|
||
// ── Fullscreen + Resume ────────────────────────────────────────────────────────
|
||
function schliessen() {
|
||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
||
emit('schliessen')
|
||
}
|
||
onMounted(async () => {
|
||
window.addEventListener('keydown', taste)
|
||
try { await wurzel.value?.requestFullscreen() } catch { /* CSS-Overlay reicht */ }
|
||
const stand = await api.lernstand(props.topic).catch(() => [])
|
||
fertig.value = new Set(stand.filter(s => s.status === 'fertig').map(s => s.baustein_id))
|
||
xpMap.value = Object.fromEntries(stand.map(s => [s.baustein_id, s.xp]))
|
||
const idx = screens.value.findIndex(s => s.typ === 'fundament' && !fertig.value.has(s.baustein))
|
||
pos.value = idx >= 0 ? idx : screens.value.length - 1
|
||
})
|
||
onUnmounted(() => {
|
||
window.removeEventListener('keydown', taste)
|
||
if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div ref="wurzel" class="lesemodus"
|
||
@touchstart.passive="wischStart" @touchend.passive="wischEnde">
|
||
<header class="lm-kopf">
|
||
<span class="lm-fort">{{ fortschrittText }}</span>
|
||
<button class="lm-x" title="Schließen" @click="schliessen">✕</button>
|
||
</header>
|
||
|
||
<main ref="buehne" class="lm-buehne">
|
||
<section v-if="screen.typ === 'intro'" class="lm-screen lm-intro">
|
||
<div class="lm-kapnr">Kapitel {{ screen.nr }} / {{ screen.von }}</div>
|
||
<h1>{{ screen.kap.titel }}</h1>
|
||
<p v-if="screen.kap.intro" class="lm-introtext">{{ screen.kap.intro }}</p>
|
||
<div class="lm-stats">
|
||
{{ kapStat(screen.kap).woerter.toLocaleString('de-DE') }} Wörter ·
|
||
{{ zeit(kapStat(screen.kap).minuten) }} · {{ screen.kap.sections.length }} Bausteine
|
||
</div>
|
||
</section>
|
||
|
||
<section v-else-if="screen.typ === 'fundament'" class="lm-screen">
|
||
<div class="lm-label">Fundament</div>
|
||
<h2 class="lm-titel">{{ screen.titel }}</h2>
|
||
<div class="markdown" v-html="fundamentHtml(screen)"></div>
|
||
</section>
|
||
|
||
<section v-else-if="screen.typ === 'abruf'" class="lm-screen lm-abruf">
|
||
<div class="lm-label">Abrufprüfung · {{ screen.titel }}</div>
|
||
<div v-if="stapel.length" class="lm-karte">
|
||
<div class="lm-frage markdown" v-html="render(stapel[0].inhalt.frage)"></div>
|
||
<div v-if="zeigeAntwort" class="lm-antwort markdown" v-html="render(stapel[0].inhalt.antwort)"></div>
|
||
<div class="lm-karte-akt">
|
||
<button v-if="!zeigeAntwort" @click="zeigeAntwort = true">
|
||
Antwort zeigen <kbd class="kurz">_</kbd></button>
|
||
<template v-else>
|
||
<button class="lm-falsch" @click="antworten(false)">
|
||
Falsch <kbd class="kurz">1</kbd></button>
|
||
<button class="lm-richtig" @click="antworten(true)">
|
||
Richtig <kbd class="kurz">2</kbd></button>
|
||
</template>
|
||
</div>
|
||
<div class="lm-rest">noch {{ stapel.length }} · Box {{ stapel[0].box }}</div>
|
||
</div>
|
||
<div v-else-if="alleKarten.length" class="lm-bestanden">
|
||
✓ Alle Karten absolviert — Wiedervorlage folgt automatisch.<br />
|
||
<button style="margin-top: 12px" @click="nochmalAlle">Alle erneut üben</button>
|
||
</div>
|
||
<div v-else class="lm-bestanden">✓ Abruf durch — weiter mit →</div>
|
||
</section>
|
||
|
||
<section v-else-if="screen.typ === 'pruefung'" class="lm-screen lm-pruefung">
|
||
<div class="lm-label">Prüfung · {{ screen.titel }} · Saldo {{ saldo }} / {{ SCHWELLE }}</div>
|
||
<template v-if="panel.length">
|
||
<h3 class="pf-frage">Was gilt? <span class="pf-hinweis">(0–{{ panel.length }} Aussagen können wahr sein)</span></h3>
|
||
<div class="pf-panel">
|
||
<label v-for="(x, i) in panel" :key="x.id" class="pf-aussage"
|
||
:class="{ gewaehlt: auswahl.has(x.id),
|
||
'pf-wahr': urteil(x.id) === true,
|
||
'pf-falsch2': urteil(x.id) === false }"
|
||
@click.prevent="toggle(x.id)">
|
||
<kbd class="kurz">{{ i + 1 }}</kbd>
|
||
<span class="markdown" v-html="render(x.text)"></span>
|
||
</label>
|
||
</div>
|
||
<div v-if="aufloesung" class="pf-feedback" :class="warRichtig ? 'pf-fb-ok' : 'pf-fb-bad'">
|
||
{{ warRichtig ? '✓ Panel korrekt (+1)' : '✗ Nicht exakt (−1)' }}
|
||
</div>
|
||
<div class="pf-akt">
|
||
<button v-if="!aufloesung" @click="pruefen">Prüfen <kbd class="kurz">_</kbd></button>
|
||
<button v-else @click="naechstesPanel">Nächstes Panel → <kbd class="kurz">_</kbd></button>
|
||
<span v-if="fertig.has(screen.baustein)" class="lm-bestanden" style="margin: 0">✓ bestanden</span>
|
||
</div>
|
||
</template>
|
||
<div v-else class="lm-bestanden">Keine Prüfungs-Aussagen für diesen Baustein — weiter mit →</div>
|
||
</section>
|
||
|
||
<section v-else class="lm-screen lm-ende">
|
||
<h1>Fundament abgeschlossen 🎉</h1>
|
||
<p class="lm-introtext">Schritt 2 (Anwendung) und Schritt 3 (eigene Aufgaben) folgen.</p>
|
||
</section>
|
||
</main>
|
||
|
||
<footer class="lm-fuss">
|
||
<button :disabled="pos === 0" @click="zurueck">← Zurück</button>
|
||
<button :disabled="gesperrt || pos >= screens.length - 1" @click="vor">
|
||
{{ gesperrt ? `Saldo ${saldo} / ${SCHWELLE} — weiter prüfen` : 'Weiter →' }}
|
||
</button>
|
||
</footer>
|
||
</div>
|
||
</template>
|