Files
creator2/frontend/src/components/Lesemodus.vue
2026-07-13 20:04:30 +02:00

304 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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: 14 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>