This commit is contained in:
team3
2026-09-29 13:47:21 +02:00
parent aa80a5f17c
commit 828e3978e9
6 changed files with 52 additions and 8 deletions

View File

@@ -1,3 +1,4 @@
import json
import logging
from contextlib import asynccontextmanager
@@ -27,6 +28,11 @@ class ThemaNeu(BaseModel):
hinweise: str = ""
class Position(BaseModel):
abschnitt: str | None = None
lesemodus: int | None = None
def _pruefen(name: str) -> dict:
if not state.name_ok(name) or not (t := state.thema(name)):
raise HTTPException(404, "Thema unbekannt")
@@ -94,6 +100,18 @@ async def zustand(name: str):
"deutung": st.get("deutung") or state.deutung(name), "ereignisse": st["ereignisse"][-100:]}
@app.get("/api/topics/{name}/position")
async def position_lesen(name: str):
_pruefen(name)
return state.position(name)
@app.put("/api/topics/{name}/position")
async def position_schreiben(name: str, body: Position):
_pruefen(name)
return state.position_setzen(name, {k: v for k, v in body.model_dump().items() if v is not None})
@app.get("/api/topics/{name}/guide")
async def guide(name: str):
_pruefen(name)

View File

@@ -167,3 +167,15 @@ def slug(text: str) -> str:
t = t.replace(a, b)
t = re.sub(r"[^a-z0-9]+", "-", t).strip("-")
return t[:60] or "kapitel"
def position(name: str) -> dict:
"""Leseposition (Abschnitt der Buchansicht, Bildschirm des Lesemodus) – geräteübergreifend auf dem Server."""
return _lesen(verzeichnis(name) / "position.json", {})
def position_setzen(name: str, werte: dict) -> dict:
p = position(name)
p.update(werte)
_schreiben(verzeichnis(name) / "position.json", p)
return p

View File

@@ -1,10 +1,10 @@
{
"name": "primer-frontend",
"name": "buchwerk-frontend",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "primer-frontend",
"name": "buchwerk-frontend",
"dependencies": {
"dompurify": "^3.2.0",
"highlight.js": "^11.11.0",

View File

@@ -23,6 +23,8 @@ export const api = {
reset: (t) => req(`/topics/${e(t)}/reset`, { method: 'POST' }),
state: (t) => req(`/topics/${e(t)}/state`),
guide: (t) => req(`/topics/${e(t)}/guide`),
position: (t) => req(`/topics/${e(t)}/position`),
positionSetzen: (t, daten) => req(`/topics/${e(t)}/position`, { method: 'PUT', body: daten }),
guideMdUrl: (t) => `/api/topics/${e(t)}/guide.md`,
pdfUrl: (t) => `/api/topics/${e(t)}/buch.pdf`,
}

View File

@@ -26,6 +26,12 @@ const aktiv = ref('')
const lese = ref(null)
let beobachter = null
let ladeSeq = 0
// Leseposition auf dem Server merken (geräteübergreifend), gebündelt alle 1,5 s
let merkTimer = null
function merken(id) {
clearTimeout(merkTimer)
merkTimer = setTimeout(() => api.positionSetzen(props.topic, { abschnitt: id }).catch(() => {}), 1500)
}
const cache = new Map() // Schlüssel: Abschnitt + Textlänge → unveränderte Abschnitte werden nicht neu gerendert
onUnmounted(() => { beobachter?.disconnect() })
@@ -33,13 +39,13 @@ onUnmounted(() => { beobachter?.disconnect() })
async function laden() {
if (!props.topic) { daten.value = null; return }
const seq = ++ladeSeq
const d = await api.guide(props.topic)
const [d, pos] = await Promise.all([api.guide(props.topic), api.position(props.topic).catch(() => ({}))])
if (seq !== ladeSeq) return
daten.value = d
await nextTick()
spy()
// Deep-Link (#a03-1 / #k03) hat Vorrang vor der gemerkten Position; beides nur beim ersten Laden
const ziel = location.hash.slice(1) || localStorage.getItem('pos:' + props.topic)
const ziel = location.hash.slice(1) || pos.abschnitt
if (ziel && !aktiv.value) document.getElementById(ziel)?.scrollIntoView({ block: 'start' })
}
watch(() => props.topic, () => { aktiv.value = ''; cache.clear(); laden() }, { immediate: true })
@@ -73,7 +79,7 @@ const kapBadge = { geschrieben: ['gelb', 'geschrieben'], geprueft: ['blau', 'gep
function spy() {
beobachter?.disconnect()
beobachter = new IntersectionObserver((es) => {
for (const e of es) if (e.isIntersecting) { aktiv.value = e.target.id; localStorage.setItem('pos:' + props.topic, e.target.id) }
for (const e of es) if (e.isIntersecting) { aktiv.value = e.target.id; merken(e.target.id) }
}, { root: lese.value, rootMargin: '0px 0px -75% 0px' })
document.querySelectorAll('.buch [data-spy]').forEach((el) => beobachter.observe(el))
}

View File

@@ -1,6 +1,7 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { render, zeit } from '../markdown.js'
import { api } from '../api.js'
const props = defineProps({ daten: Object, topic: String })
const emit = defineEmits(['schliessen'])
@@ -31,7 +32,12 @@ function html(a) {
function vor() { if (pos.value < screens.value.length - 1) pos.value++ }
function zurueck() { if (pos.value > 0) pos.value-- }
watch(pos, (p) => { localStorage.setItem('lm:' + props.topic, String(p)); buehne.value?.scrollTo({ top: 0 }) })
let merkTimer = null
watch(pos, (p) => {
buehne.value?.scrollTo({ top: 0 })
clearTimeout(merkTimer)
merkTimer = setTimeout(() => api.positionSetzen(props.topic, { lesemodus: p }).catch(() => {}), 1500)
})
function taste(e) {
if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); vor() }
@@ -57,9 +63,9 @@ function wischEnde(e) {
if (Math.abs(dx) < 60 || Math.abs(dx) < 2 * Math.abs(dy)) return
dx < 0 ? vor() : zurueck()
}
onMounted(() => {
onMounted(async () => {
window.addEventListener('keydown', taste)
const merk = Number(localStorage.getItem('lm:' + props.topic) || 0)
const merk = Number((await api.position(props.topic).catch(() => ({}))).lesemodus || 0)
pos.value = Math.min(merk, screens.value.length - 1)
})
onUnmounted(() => window.removeEventListener('keydown', taste))