diff --git a/backend/main.py b/backend/main.py index 6f4b1c3..bd4b2c4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/state.py b/backend/state.py index e79897a..2ce7b4d 100644 --- a/backend/state.py +++ b/backend/state.py @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9a84675..744864d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/src/api.js b/frontend/src/api.js index d124df6..d39ce5e 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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`, } diff --git a/frontend/src/components/Guide.vue b/frontend/src/components/Guide.vue index ea816bb..329905c 100644 --- a/frontend/src/components/Guide.vue +++ b/frontend/src/components/Guide.vue @@ -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)) } diff --git a/frontend/src/components/Lesemodus.vue b/frontend/src/components/Lesemodus.vue index e1f5331..40b8819 100644 --- a/frontend/src/components/Lesemodus.vue +++ b/frontend/src/components/Lesemodus.vue @@ -1,6 +1,7 @@