Adressen: Slug und Version im Pfad statt Parameter

Seite: /<slug>[/v<N>][/lauf|/buch|/lesen][/<Abschnitt>], Zurück-Taste je
Abschnitt. API: /api/themen/<slug>/v<N>/… mit Version als Pflichtteil des
Pfads; /api/topics entfällt. Slugs stehen in thema.json und werden beim
Start nachgetragen. Unbekannte API-Pfade liefern 404 statt der Seite.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
team3
2026-10-05 17:12:42 +02:00
parent 1b6899638b
commit d15ec49714
8 changed files with 284 additions and 191 deletions

77
API.md
View File

@@ -2,24 +2,36 @@
Backend: FastAPI auf Port 8020, liefert `frontend/dist/` aus. Alle Pfade unter `/api`. Backend: FastAPI auf Port 8020, liefert `frontend/dist/` aus. Alle Pfade unter `/api`.
## Themen ## Adressen
- `GET /api/topics` → `[{name, titel, status, laeuft, stufe}]` Ein Thema hat einen Slug („Godot 4“ → `godot-4`) und Versionen `v1`, `v2`, … mit eigenem Lauf, Buch, Quellen und Leseposition.
- `status`: `neu | laeuft | pausiert | fertig | fehler` Die Version ist in der API immer Teil des Pfads; „neueste“ löst die Oberfläche selbst auf.
- `POST /api/topics` `{name, hinweise}` → Topic (name = Thema, z. B. „Godot 4“; hinweise optional, z. B. „für Entwickler, ab Version 4.6“)
- `DELETE /api/topics/{name}` Seite: `/<slug>`, `/<slug>/v2/lauf`, `/<slug>/v2/buch`, `/<slug>/v2/buch/3.4` (Abschnitt; `3` = Kapitel, `vorwort`), `/<slug>/v2/lesen`.
- `POST /api/topics/{name}/start` – startet oder setzt fort (resümiert an der Stufe, die noch offen ist) Ohne `v2` gilt die neueste Version.
- `POST /api/topics/{name}/pause` – bricht laufende Agenten ab; Neustart mit `start`
- `POST /api/topics/{name}/reset` – löscht alles außer `thema.json` ## Themen und Versionen
- `GET /api/themen` → `[{slug, name, titel, hinweise, angelegt, status, laeuft, stufe, versionen: [{v, status, laeuft, stufe, angelegt, kosten_usd}]}]`
– `status/laeuft/stufe` gelten für die neueste Version; `status`: `neu | laeuft | pausiert | fertig | fehler`
- `POST /api/themen` `{name, hinweise}` → Thema wie oben (legt `v1` an)
- `GET /api/themen/{slug}` → Thema wie oben
- `DELETE /api/themen/{slug}` – ganzes Thema
- `POST /api/themen/{slug}/versionen` → `{"v": N}` – nächste Version, gleiche Eingaben, ohne Start
- `DELETE /api/themen/{slug}/v{N}` – nur diese Version
- `POST /api/themen/{slug}/v{N}/start` – startet oder setzt fort (resümiert an der offenen Stufe)
- `POST /api/themen/{slug}/v{N}/pause` – bricht laufende Agenten ab; Neustart mit `start`
- `POST /api/themen/{slug}/v{N}/reset` – löscht alles in der Version außer dem Zustand „neu“
- `GET|PUT /api/themen/{slug}/v{N}/position` – Leseposition `{abschnitt, lesemodus}`
## Zustand eines Laufs ## Zustand eines Laufs
`GET /api/topics/{name}/state` → `GET /api/themen/{slug}/v{N}` →
```json ```json
{ {
"name": "Godot 4", "titel": "Godot 4", "hinweise": "…", "slug": "godot-4", "name": "Godot 4", "version": 1, "titel": "Godot 4", "hinweise": "…",
"status": "laeuft", "grund": "", "stufe": "schreiben", "status": "laeuft", "grund": "", "stufe": "schreiben", "angelegt": "2026-09-28T10:00:00Z",
"deutung": {"titel": "Godot 4", "untertitel": "…", "deutung": "…", "buchtyp": "werkzeug|wissen", "zielgruppe": "…"}, "deutung": {"titel": "Godot 4", "untertitel": "…", "deutung": "…", "buchtyp": "werkzeug|wissen", "zielgruppe": "…"},
"stufen": [ "stufen": [
{"id": "recherche", "titel": "Thema deuten und Quellen beschaffen", "status": "fertig", "fertig": 1, "gesamt": 1, {"id": "recherche", "titel": "Thema deuten und Quellen beschaffen", "status": "fertig", "fertig": 1, "gesamt": 1,
@@ -32,10 +44,10 @@ Backend: FastAPI auf Port 8020, liefert `frontend/dist/` aus. Alle Pfade unter `
], ],
"kosten_usd": 12.3, "tokens": 4100000, "kosten_usd": 12.3, "tokens": 4100000,
"agenten": [ "agenten": [
{"id": "schreiben-03", "stufe": "schreiben", "name": "Kapitel 3 schreiben", "status": "laeuft|fertig|fehler|abgebrochen", {"id": "kapitel-03", "stufe": "schreiben", "name": "Kapitel 3 schreiben", "status": "laeuft|fertig|fehler|abgebrochen",
"kosten_usd": 0.9, "tokens": 180000, "werkzeuge": 41, "gestartet": "…", "beendet": "…", "bericht": "<Abschlussbericht des Agenten, Markdown>"} "kosten_usd": 0.9, "tokens": 180000, "werkzeuge": 41, "gestartet": "…", "beendet": "…", "bericht": "<Abschlussbericht, Markdown>"}
], ],
"ereignisse": [{"zeit": "…", "agent": "schreiben-03", "art": "tool|text|start|ende|fehler", "text": "Write buch/03-dal.md"}] "ereignisse": [{"zeit": "…", "agent": "kapitel-03", "art": "tool|text|start|ende|fehler", "text": "Write buch/03-dal.md"}]
} }
``` ```
@@ -43,7 +55,7 @@ Backend: FastAPI auf Port 8020, liefert `frontend/dist/` aus. Alle Pfade unter `
## Buch ## Buch
`GET /api/topics/{name}/guide` → `GET /api/themen/{slug}/v{N}/buch` →
```json ```json
{ {
@@ -62,30 +74,25 @@ Backend: FastAPI auf Port 8020, liefert `frontend/dist/` aus. Alle Pfade unter `
} }
``` ```
- `GET /api/topics/{name}/guide.md` – ganzes Buch als Markdown - `GET /api/themen/{slug}/v{N}/buch.md` – ganzes Buch als Markdown
- `GET /api/topics/{name}/buch.pdf` – PDF (404, solange nicht gebaut) - `GET /api/themen/{slug}/v{N}/buch.pdf` – PDF (404, solange nicht gebaut)
Solange ein Lauf läuft, enthält `guide` die bereits geschriebenen Kapitel (Status `geschrieben` bis der Prüfer fertig ist). Solange ein Lauf läuft, enthält `buch` die bereits geschriebenen Kapitel (Status `geschrieben`, bis der Prüfer fertig ist).
## Dateien im Speicher ## Dateien im Speicher
``` ```
storage/<name>/ storage/<Thema>/
thema.json {name, titel, hinweise, angelegt} thema.json {name, slug, titel, hinweise, angelegt}
state.json Zustand wie oben (ohne guide) v1/, v2/, … je Version:
deutung.json vom Recherche-Agenten state.json Zustand wie oben (ohne Buch)
quellen/ QUELLEN.md + geklonte/geladene Quellen deutung.json vom Recherche-Agenten
buch/ STIL.md, GLIEDERUNG.md, 00-vorwort.md, NN-*.md, README.md, buch.md, buch.pdf position.json Leseposition
berichte/ <agent-id>.md (Abschlussberichte) quellen/ QUELLEN.md + geklonte/geladene Quellen
log.jsonl Ereignisse buch/ STIL.md, GLIEDERUNG.md, 00-vorwort.md, NN-*.md, README.md, buch.md, buch.pdf
berichte/ <agent-id>.md (Abschlussberichte)
arbeit/ Arbeitsdateien der Agenten
log.jsonl Ereignisse
``` ```
## Versionen Alte Ablagen (alles direkt unter `storage/<Thema>/`) werden beim Start nach `v1/` verschoben, fehlende Slugs nachgetragen.
Ein Thema kann mehrere Versionen haben (gleiche Eingaben, unabhängiger Lauf, eigenes Buch und eigene Leseposition).
Ablage: `storage/<Thema>/thema.json` und `storage/<Thema>/v<N>/…`. Alte Ablagen werden beim Start nach `v1/` verschoben.
- `GET /api/topics` liefert je Thema `versionen: [{v, status, laeuft, stufe, angelegt, kosten_usd}]`; die Felder `status/laeuft/stufe` gelten für die neueste Version.
- `POST /api/topics/{name}/version` → `{"v": N}` legt die nächste Version an (ohne Start).
- Alle Endpunkte unter `/api/topics/{name}/…` nehmen `?v=N`; ohne Angabe gilt die neueste Version.
- `DELETE /api/topics/{name}?v=N` löscht nur diese Version, ohne `v` das ganze Thema.

View File

@@ -55,6 +55,7 @@ pull:
loeschen: loeschen:
@[ -n "$(TOPIC)" ] || { echo "TOPIC=<Name> angeben (optional V=<Nr>)"; exit 1; } @[ -n "$(TOPIC)" ] || { echo "TOPIC=<Name> angeben (optional V=<Nr>)"; exit 1; }
@printf '%s auf dem Server und lokal unwiderruflich löschen? [j/N] ' "$(if $(V),Version $(V) von „$(TOPIC)“,Thema „$(TOPIC)“ mit allen Versionen)"; read a; [ "$$a" = "j" ] @printf '%s auf dem Server und lokal unwiderruflich löschen? [j/N] ' "$(if $(V),Version $(V) von „$(TOPIC)“,Thema „$(TOPIC)“ mit allen Versionen)"; read a; [ "$$a" = "j" ]
curl -fsS -X DELETE "$(URL)/api/topics/$$(python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1]))' "$(TOPIC)")$(if $(V),?v=$(V),)" && echo @slug=$$(curl -fsS "$(URL)/api/themen" | python3 -c 'import json,sys; n=sys.argv[1]; print(next(t["slug"] for t in json.load(sys.stdin) if t["name"]==n))' "$(TOPIC)") && \
curl -fsS -X DELETE "$(URL)/api/themen/$$slug$(if $(V),/v$(V),)" && echo
rm -rf "storage/$(TOPIC)$(if $(V),/v$(V),)" rm -rf "storage/$(TOPIC)$(if $(V),/v$(V),)"
@echo "Gelöscht: $(TOPIC)$(if $(V), v$(V),)" @echo "Gelöscht: $(TOPIC)$(if $(V), v$(V),)"

View File

@@ -36,11 +36,18 @@ class Position(BaseModel):
lesemodus: str | int | None = None # neu: key des Bildschirms, alt: Index lesemodus: str | int | None = None # neu: key des Bildschirms, alt: Index
def _key(name: str, v: int | None = None) -> str: def _thema(slug: str) -> dict:
"""Themenname + optionale Version → Schlüssel „Thema/vN“ (ohne v: neueste).""" name = state.name_von_slug(slug)
if not state.name_ok(name) or not state.thema(name): t = state.thema(name) if name else None
if not t:
raise HTTPException(404, "Thema unbekannt") raise HTTPException(404, "Thema unbekannt")
key = state.schluessel(name, v) return t
def _key(slug: str, n: int) -> str:
"""Slug + Versionsnummer → interner Schlüssel „Thema/vN“."""
t = _thema(slug)
key = state.schluessel(t["name"], n)
if not key: if not key:
raise HTTPException(404, "Version unbekannt") raise HTTPException(404, "Version unbekannt")
return key return key
@@ -54,111 +61,120 @@ def _version_info(name: str, n: int) -> dict:
"angelegt": angelegt, "kosten_usd": st.get("kosten_usd", 0)} "angelegt": angelegt, "kosten_usd": st.get("kosten_usd", 0)}
@app.get("/api/topics") def _thema_info(t: dict) -> dict | None:
async def topics(): vs = [_version_info(t["name"], n) for n in state.versionen(t["name"])]
aus = [] if not vs:
for t in state.themen(): return None
vs = [_version_info(t["name"], n) for n in state.versionen(t["name"])] neueste = vs[-1]
if not vs: return {"slug": t["slug"], "name": t["name"], "titel": t["titel"], "hinweise": t.get("hinweise", ""),
continue "angelegt": t.get("angelegt", ""), "status": neueste["status"], "laeuft": neueste["laeuft"],
neueste = vs[-1] "stufe": neueste["stufe"], "versionen": vs}
aus.append({"name": t["name"], "titel": t["titel"], "status": neueste["status"],
"laeuft": neueste["laeuft"], "stufe": neueste["stufe"], "versionen": vs})
return aus
@app.post("/api/topics") @app.get("/api/themen")
async def topic_anlegen(body: ThemaNeu): async def themen():
return [i for t in state.themen() if (i := _thema_info(t))]
@app.post("/api/themen")
async def thema_anlegen(body: ThemaNeu):
name = body.name.strip() name = body.name.strip()
if not state.name_ok(name): if not state.name_ok(name):
raise HTTPException(400, "Ungültiger Name") raise HTTPException(400, "Ungültiger Name")
try: try:
return state.anlegen(name, body.hinweise) return _thema_info(state.anlegen(name, body.hinweise))
except ValueError as e: except ValueError as e:
raise HTTPException(409, str(e)) raise HTTPException(409, str(e))
@app.post("/api/topics/{name}/version") @app.get("/api/themen/{slug}")
async def version_anlegen(name: str): async def thema(slug: str):
"""Neue Version: gleiche Eingaben, frischer Lauf, ohne Wissen über die Vorgänger.""" return _thema_info(_thema(slug))
_key(name)
key = state.version_anlegen(name)
return {"v": int(key.rsplit("/v", 1)[1])}
@app.delete("/api/topics/{name}") @app.delete("/api/themen/{slug}")
async def topic_loeschen(name: str, v: int | None = None): async def thema_loeschen(slug: str):
if v is None: t = _thema(slug)
_key(name) for n in state.versionen(t["name"]):
for n in state.versionen(name): await pipeline.pausieren(f"{t['name']}/v{n}")
await pipeline.pausieren(f"{name}/v{n}") state.loeschen(t["name"])
state.loeschen(name)
else:
key = _key(name, v)
await pipeline.pausieren(key)
state.version_loeschen(key)
return {"ok": True} return {"ok": True}
@app.post("/api/topics/{name}/start") @app.post("/api/themen/{slug}/versionen")
async def start(name: str, v: int | None = None): async def version_anlegen(slug: str):
key = _key(name, v) """Neue Version: gleiche Eingaben, frischer Lauf, ohne Wissen über die Vorgänger."""
key = state.version_anlegen(_thema(slug)["name"])
return {"v": int(key.rsplit("/v", 1)[1])}
@app.get("/api/themen/{slug}/v{n}")
async def zustand(slug: str, n: int):
key = _key(slug, n)
t = _thema(slug)
st = state.zustand(key)
return {"slug": slug, "name": t["name"], "version": n, "titel": t["titel"], "hinweise": t.get("hinweise", ""), **st,
"deutung": st.get("deutung") or state.deutung(key), "ereignisse": st["ereignisse"][-100:]}
@app.delete("/api/themen/{slug}/v{n}")
async def version_loeschen(slug: str, n: int):
key = _key(slug, n)
await pipeline.pausieren(key)
state.version_loeschen(key)
return {"ok": True}
@app.post("/api/themen/{slug}/v{n}/start")
async def start(slug: str, n: int):
key = _key(slug, n)
if state.zustand(key)["status"] == "fertig": if state.zustand(key)["status"] == "fertig":
raise HTTPException(400, "Lauf ist fertig – erst zurücksetzen") raise HTTPException(400, "Lauf ist fertig – erst zurücksetzen")
pipeline.starten(key) pipeline.starten(key)
return {"ok": True} return {"ok": True}
@app.post("/api/topics/{name}/pause") @app.post("/api/themen/{slug}/v{n}/pause")
async def pause(name: str, v: int | None = None): async def pause(slug: str, n: int):
await pipeline.pausieren(_key(name, v)) await pipeline.pausieren(_key(slug, n))
return {"ok": True} return {"ok": True}
@app.post("/api/topics/{name}/reset") @app.post("/api/themen/{slug}/v{n}/reset")
async def reset(name: str, v: int | None = None): async def reset(slug: str, n: int):
key = _key(name, v) key = _key(slug, n)
await pipeline.pausieren(key) await pipeline.pausieren(key)
state.zuruecksetzen(key) state.zuruecksetzen(key)
return {"ok": True} return {"ok": True}
@app.get("/api/topics/{name}/state") @app.get("/api/themen/{slug}/v{n}/position")
async def zustand(name: str, v: int | None = None): async def position_lesen(slug: str, n: int):
key = _key(name, v) return state.position(_key(slug, n))
t = state.thema(name)
st = state.zustand(key)
return {"name": name, "version": int(key.rsplit("/v", 1)[1]), "titel": t["titel"], "hinweise": t.get("hinweise", ""), **st,
"deutung": st.get("deutung") or state.deutung(key), "ereignisse": st["ereignisse"][-100:]}
@app.get("/api/topics/{name}/position") @app.put("/api/themen/{slug}/v{n}/position")
async def position_lesen(name: str, v: int | None = None): async def position_schreiben(slug: str, n: int, body: Position):
return state.position(_key(name, v)) return state.position_setzen(_key(slug, n), {k: val for k, val in body.model_dump().items() if val is not None})
@app.put("/api/topics/{name}/position") @app.get("/api/themen/{slug}/v{n}/buch")
async def position_schreiben(name: str, body: Position, v: int | None = None): async def buch(slug: str, n: int):
return state.position_setzen(_key(name, v), {k: val for k, val in body.model_dump().items() if val is not None}) return montage.guide(_key(slug, n))
@app.get("/api/topics/{name}/guide") @app.get("/api/themen/{slug}/v{n}/buch.md", response_class=PlainTextResponse)
async def guide(name: str, v: int | None = None): async def buch_md(slug: str, n: int):
return montage.guide(_key(name, v)) return montage.markdown(_key(slug, n))
@app.get("/api/topics/{name}/guide.md", response_class=PlainTextResponse) @app.get("/api/themen/{slug}/v{n}/buch.pdf")
async def guide_md(name: str, v: int | None = None): async def buch_pdf(slug: str, n: int):
return montage.markdown(_key(name, v)) key = _key(slug, n)
@app.get("/api/topics/{name}/buch.pdf")
async def buch_pdf(name: str, v: int | None = None):
key = _key(name, v)
p = state.verzeichnis(key) / "buch" / "buch.pdf" p = state.verzeichnis(key) / "buch" / "buch.pdf"
if not p.exists(): if not p.exists():
raise HTTPException(404, "PDF noch nicht gebaut") raise HTTPException(404, "PDF noch nicht gebaut")
return FileResponse(p, media_type="application/pdf", filename=f"{name} v{key.rsplit('/v', 1)[1]}.pdf") return FileResponse(p, media_type="application/pdf", filename=f"{slug}-v{n}.pdf")
if config.FRONTEND_DIST.exists(): if config.FRONTEND_DIST.exists():
@@ -166,4 +182,6 @@ if config.FRONTEND_DIST.exists():
@app.get("/{pfad:path}") @app.get("/{pfad:path}")
async def spa(pfad: str): async def spa(pfad: str):
if pfad.startswith("api/"):
raise HTTPException(404, "Unbekannter API-Pfad")
return FileResponse(config.FRONTEND_DIST / "index.html") return FileResponse(config.FRONTEND_DIST / "index.html")

View File

@@ -41,6 +41,20 @@ def schluessel(name: str, v: int | None = None) -> str | None:
return f"{name}/v{vs[-1] if v is None else v}" return f"{name}/v{vs[-1] if v is None else v}"
def slug_frei(name: str) -> str:
"""Eindeutiger Slug für die Adresse: „Godot 4“ → „godot-4“; bei Kollision „godot-4-2“."""
basis = slug(name)
vergeben = {t.get("slug") for t in themen()}
k, i = basis, 2
while k in vergeben:
k, i = f"{basis}-{i}", i + 1
return k
def name_von_slug(sl: str) -> str | None:
return next((t["name"] for t in themen() if t.get("slug") == sl), None)
def version_anlegen(name: str) -> str: def version_anlegen(name: str) -> str:
n = (versionen(name) or [0])[-1] + 1 n = (versionen(name) or [0])[-1] + 1
key = f"{name}/v{n}" key = f"{name}/v{n}"
@@ -60,8 +74,12 @@ def version_loeschen(key: str) -> None:
def migrieren() -> None: def migrieren() -> None:
"""Alte Ablage (alles direkt unter storage/<Thema>/) einmalig nach v1/ verschieben.""" """Alte Ablage (alles direkt unter storage/<Thema>/) einmalig nach v1/ verschieben; Slugs nachtragen."""
for d in config.STORAGE.glob("*/"): for d in config.STORAGE.glob("*/"):
t = _lesen(d / "thema.json", None)
if t and not t.get("slug"):
t["slug"] = slug_frei(t["name"])
_schreiben(d / "thema.json", t)
if not (d / "thema.json").exists() or not (d / "state.json").exists() or (d / "v1").exists(): if not (d / "thema.json").exists() or not (d / "state.json").exists() or (d / "v1").exists():
continue continue
v1 = d / "v1" v1 = d / "v1"
@@ -106,7 +124,7 @@ def anlegen(name: str, hinweise: str) -> dict:
if d.exists(): if d.exists():
raise ValueError("Thema existiert schon") raise ValueError("Thema existiert schon")
d.mkdir(parents=True) d.mkdir(parents=True)
t = {"name": name, "titel": name, "hinweise": hinweise.strip(), "angelegt": jetzt()} t = {"name": name, "slug": slug_frei(name), "titel": name, "hinweise": hinweise.strip(), "angelegt": jetzt()}
_schreiben(d / "thema.json", t) _schreiben(d / "thema.json", t)
version_anlegen(name) version_anlegen(name)
return t return t
@@ -213,7 +231,7 @@ def slug(text: str) -> str:
for a, b in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")): for a, b in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")):
t = t.replace(a, b) t = t.replace(a, b)
t = re.sub(r"[^a-z0-9]+", "-", t).strip("-") t = re.sub(r"[^a-z0-9]+", "-", t).strip("-")
return t[:60] or "kapitel" return t[:60] or "thema"
def position(name: str) -> dict: def position(name: str) -> dict:

View File

@@ -4,11 +4,32 @@ import { api } from './api.js'
import Lauf from './components/Lauf.vue' import Lauf from './components/Lauf.vue'
import Guide from './components/Guide.vue' import Guide from './components/Guide.vue'
const params = new URLSearchParams(location.search) // Adresse: /<slug>[/v<N>][/lauf|/buch|/lesen][/<Abschnitt>] – ohne Version gilt die neueste
const TABS = ['lauf', 'buch', 'lesen']
function routeLesen() {
const t = location.pathname.split('/').filter(Boolean).map(decodeURIComponent)
const r = { slug: t[0] || '', v: null, tab: 'lauf', nr: '' }
let i = 1
if (/^v\d+$/.test(t[i] || '')) { r.v = Number(t[i].slice(1)); i++ }
if (TABS.includes(t[i] || '')) { r.tab = t[i]; i++ }
r.nr = t[i] || ''
return r
}
function pfadVon(r) {
if (!r.slug) return '/'
let p = '/' + encodeURIComponent(r.slug)
if (r.v) p += '/v' + r.v
p += '/' + r.tab
if (r.tab !== 'lauf' && r.nr) p += '/' + encodeURIComponent(r.nr)
return p
}
const start = routeLesen()
const topics = ref([]) const topics = ref([])
const topic = ref(params.get('topic') || localStorage.getItem('topic') || '') const slug = ref(start.slug || localStorage.getItem('slug') || '')
const version = ref(Number(params.get('v')) || null) // null = neueste Version const version = ref(start.v) // null = neueste Version
const tab = ref((params.get('tab') || localStorage.getItem('tab')) === 'buch' ? 'buch' : 'lauf') const tab = ref(start.tab)
const nr = ref(start.nr) // Abschnitt im Buch, z. B. 3.4 oder „vorwort“
const theme = ref(localStorage.getItem('theme') || 'papier') const theme = ref(localStorage.getItem('theme') || 'papier')
const state = ref(null) const state = ref(null)
const fehler = ref('') const fehler = ref('')
@@ -27,38 +48,37 @@ watch(theme, themeAnwenden)
media.addEventListener('change', themeAnwenden) media.addEventListener('change', themeAnwenden)
themeAnwenden() themeAnwenden()
// URL spiegelt Thema und Tab; beim Themenwechsel fällt der Deep-Link (#a…/#k…) weg // Navigation: pushState für Klicks (Zurück-Taste funktioniert), replaceState beim Scrollen
function urlSchreiben(hashBehalten) { function geheZu(r, ersetzen = false) {
const p = new URLSearchParams() const ziel = { slug: slug.value, v: version.value, tab: tab.value, nr: nr.value, ...r }
if (topic.value) p.set('topic', topic.value) if (ziel.tab === 'lauf') ziel.nr = ''
if (version.value) p.set('v', version.value) const pfad = pfadVon(ziel)
p.set('tab', tab.value) if (pfad !== location.pathname) history[ersetzen ? 'replaceState' : 'pushState'](null, '', pfad)
history.replaceState(null, '', location.pathname + '?' + p + (hashBehalten ? location.hash : '')) slug.value = ziel.slug; version.value = ziel.v; tab.value = ziel.tab; nr.value = ziel.nr
} }
watch(topic, (t) => { localStorage.setItem('topic', t); urlSchreiben(false) }) function popstate() { const r = routeLesen(); slug.value = r.slug; version.value = r.v; tab.value = r.tab; nr.value = r.nr }
watch(version, () => urlSchreiben(false)) watch(slug, (sl) => { if (sl) localStorage.setItem('slug', sl) })
watch(tab, (t) => { localStorage.setItem('tab', t); urlSchreiben(true) })
const aktuell = computed(() => topics.value.find((t) => t.name === topic.value)) const aktuell = computed(() => topics.value.find((t) => t.slug === slug.value))
const versionen = computed(() => aktuell.value?.versionen || []) const versionen = computed(() => aktuell.value?.versionen || [])
const neueste = computed(() => versionen.value.length ? versionen.value[versionen.value.length - 1].v : null) const neueste = computed(() => versionen.value.length ? versionen.value[versionen.value.length - 1].v : null)
const versionAktiv = computed(() => version.value || neueste.value) const versionAktiv = computed(() => version.value || neueste.value)
watch([aktuell, tab, versionAktiv], ([a, t, v]) => { watch([aktuell, tab, versionAktiv], ([a, t, v]) => {
if (t !== 'buch') document.title = a ? `${a.titel}${versionen.value.length > 1 ? ' v' + v : ''} – buchwerk` : 'buchwerk' if (t === 'lauf') document.title = a ? `${a.titel}${versionen.value.length > 1 ? ' v' + v : ''} – buchwerk` : 'buchwerk'
}, { immediate: true }) }, { immediate: true })
function datum(iso) { return iso ? new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' }) : '' } function datum(iso) { return iso ? new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit' }) : '' }
function themaWaehlen(name) { topic.value = name; version.value = null; tab.value = 'lauf'; navAuf.value = false } function themaWaehlen(sl) { geheZu({ slug: sl, v: null, tab: 'lauf' }); navAuf.value = false }
function versionWaehlen(name, v) { topic.value = name; version.value = v; tab.value = 'buch'; navAuf.value = false } function versionWaehlen(sl, v) { geheZu({ slug: sl, v, tab: 'buch', nr: '' }); navAuf.value = false }
const laeuft = computed(() => state.value?.status === 'laeuft') const laeuft = computed(() => state.value?.status === 'laeuft')
const fortschritt = computed(() => (state.value?.stufen || []).reduce((n, s) => n + (s.fertig || 0), 0)) const fortschritt = computed(() => (state.value?.stufen || []).reduce((n, s) => n + (s.fertig || 0), 0))
async function topicsLaden() { async function topicsLaden() {
try { try {
topics.value = await api.topics() topics.value = await api.themen()
fehler.value = '' fehler.value = ''
if (topic.value && !topics.value.some((t) => t.name === topic.value)) topic.value = '' if (slug.value && !topics.value.some((t) => t.slug === slug.value)) slug.value = ''
if (!slug.value && topics.value.length) geheZu({ slug: topics.value[0].slug, v: null, tab: 'lauf' }, true)
if (version.value && !versionen.value.some((x) => x.v === version.value)) version.value = null if (version.value && !versionen.value.some((x) => x.v === version.value)) version.value = null
if (!topic.value && topics.value.length) topic.value = topics.value[0].name
} catch (e) { } catch (e) {
// Server gerade nicht erreichbar (z. B. Neustart): alle 3 s erneut versuchen, bis er antwortet // Server gerade nicht erreichbar (z. B. Neustart): alle 3 s erneut versuchen, bis er antwortet
fehler.value = 'Server nicht erreichbar — versuche es weiter …' fehler.value = 'Server nicht erreichbar — versuche es weiter …'
@@ -66,53 +86,55 @@ async function topicsLaden() {
} }
} }
async function stateLaden() { async function stateLaden() {
if (!topic.value) { state.value = null; return } if (!slug.value || !versionAktiv.value) { state.value = null; return }
try { state.value = await api.state(topic.value, version.value) } catch (e) { fehler.value = String(e.message || e) } try { state.value = await api.state(slug.value, versionAktiv.value) } catch (e) { fehler.value = String(e.message || e) }
} }
function pollen() { function pollen() {
clearInterval(timer) clearInterval(timer)
timer = setInterval(async () => { timer = setInterval(async () => {
if (!topic.value) return if (!slug.value) return
await stateLaden() await stateLaden()
if (!laeuft.value) { clearInterval(timer); timer = null; await topicsLaden() } if (!laeuft.value) { clearInterval(timer); timer = null; await topicsLaden() }
}, 2000) }, 2000)
} }
watch([topic, version], async () => { await stateLaden(); if (laeuft.value) pollen() }) watch([slug, versionAktiv], async () => { await stateLaden(); if (laeuft.value) pollen() })
watch(laeuft, (l) => { if (l && !timer) pollen() }) watch(laeuft, (l) => { if (l && !timer) pollen() })
async function anlegen() { async function anlegen() {
const name = neuName.value.trim() const name = neuName.value.trim()
if (!name) return if (!name) return
try { try {
await api.anlegen(name, neuHinweise.value.trim()) const t = await api.anlegen(name, neuHinweise.value.trim())
neuName.value = '' neuName.value = ''
neuHinweise.value = '' neuHinweise.value = ''
await topicsLaden() await topicsLaden()
themaWaehlen(name) themaWaehlen(t.slug)
} catch (e) { fehler.value = String(e.message || e) } } catch (e) { fehler.value = String(e.message || e) }
} }
// Neue Version: gleiche Eingaben, frischer Lauf; die alte Version bleibt samt Leseposition erhalten. // Neue Version: gleiche Eingaben, frischer Lauf; die alte Version bleibt samt Leseposition erhalten.
async function neueVersion() { async function neueVersion() {
try { try {
const r = await api.versionAnlegen(topic.value) const r = await api.versionAnlegen(slug.value)
await topicsLaden() await topicsLaden()
version.value = r.v geheZu({ v: r.v, tab: 'lauf' })
tab.value = 'lauf' await aktion(() => api.start(slug.value, r.v))
await aktion(() => api.start(topic.value, r.v))
} catch (e) { fehler.value = String(e.message || e) } } catch (e) { fehler.value = String(e.message || e) }
} }
async function aktion(fn) { async function aktion(fn) {
try { await fn(); await stateLaden(); await topicsLaden(); if (laeuft.value) pollen() } try { await fn(); await stateLaden(); await topicsLaden(); if (laeuft.value) pollen() }
catch (e) { fehler.value = String(e.message || e) } catch (e) { fehler.value = String(e.message || e) }
} }
async function loeschen(name) { async function loeschen(t) {
if (!confirm(`Thema „${name}“ löschen?`)) return if (!confirm(`Thema „${t.titel}“ löschen?`)) return
await aktion(() => api.loeschen(name)) await aktion(() => api.loeschen(t.slug))
} }
function esc(e) { if (e.key === 'Escape') navAuf.value = false } function esc(e) { if (e.key === 'Escape') navAuf.value = false }
onMounted(async () => { window.addEventListener('keydown', esc); urlSchreiben(true); await topicsLaden(); await stateLaden(); if (laeuft.value) pollen() }) onMounted(async () => {
onUnmounted(() => { clearInterval(timer); window.removeEventListener('keydown', esc) }) window.addEventListener('keydown', esc); window.addEventListener('popstate', popstate)
await topicsLaden(); await stateLaden(); if (laeuft.value) pollen()
})
onUnmounted(() => { clearInterval(timer); window.removeEventListener('keydown', esc); window.removeEventListener('popstate', popstate) })
const statusBadge = computed(() => { const statusBadge = computed(() => {
const s = state.value?.status const s = state.value?.status
@@ -121,24 +143,23 @@ const statusBadge = computed(() => {
}) })
function tokens(n) { return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? Math.round(n / 1e3) + 'k' : String(n || 0) } function tokens(n) { return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? Math.round(n / 1e3) + 'k' : String(n || 0) }
</script> </script>
<template> <template>
<div class="app" :class="{ 'tab-buch': tab === 'buch' }"> <div class="app" :class="{ 'tab-buch': tab !== 'lauf' }">
<a class="skip" href="#inhalt">Zum Inhalt springen</a> <a class="skip" href="#inhalt">Zum Inhalt springen</a>
<div v-if="navAuf" class="backdrop" @click="navAuf = false"></div> <div v-if="navAuf" class="backdrop" @click="navAuf = false"></div>
<aside class="sidebar" :class="{ offen: navAuf }"> <aside class="sidebar" :class="{ offen: navAuf }">
<button class="schliessen sekundaer klein" @click="navAuf = false">✕ Schließen</button> <button class="schliessen sekundaer klein" @click="navAuf = false">✕ Schließen</button>
<div class="topics"> <div class="topics">
<template v-for="t in topics" :key="t.name"> <template v-for="t in topics" :key="t.slug">
<div class="topic-eintrag" :class="{ aktiv: t.name === topic && (t.versionen.length === 1 || tab === 'lauf') }" <div class="topic-eintrag" :class="{ aktiv: t.slug === slug && (t.versionen.length === 1 || tab === 'lauf') }"
@click="themaWaehlen(t.name)"> @click="themaWaehlen(t.slug)">
<span class="punkt" :class="t.laeuft ? 'laeuft' : t.status"></span> <span class="punkt" :class="t.laeuft ? 'laeuft' : t.status"></span>
<span style="flex: 1">{{ t.titel }}</span> <span style="flex: 1">{{ t.titel }}</span>
<button v-if="t.versionen.length === 1 && t.status !== 'fertig'" class="klein sekundaer" title="Löschen" @click.stop="loeschen(t.name)">✕</button> <button v-if="t.versionen.length === 1 && t.status !== 'fertig'" class="klein sekundaer" title="Löschen" @click.stop="loeschen(t)">✕</button>
</div> </div>
<div v-if="t.versionen.length > 1" class="versionen"> <div v-if="t.versionen.length > 1" class="versionen">
<div v-for="x in t.versionen" :key="x.v" class="topic-eintrag version" <div v-for="x in t.versionen" :key="x.v" class="topic-eintrag version"
:class="{ aktiv: t.name === topic && tab === 'buch' && x.v === versionAktiv }" @click="versionWaehlen(t.name, x.v)"> :class="{ aktiv: t.slug === slug && tab !== 'lauf' && x.v === versionAktiv }" @click="versionWaehlen(t.slug, x.v)">
<span class="punkt" :class="x.laeuft ? 'laeuft' : x.status"></span> <span class="punkt" :class="x.laeuft ? 'laeuft' : x.status"></span>
<span style="flex: 1">v{{ x.v }} <span class="dim">· {{ datum(x.angelegt) }}</span></span> <span style="flex: 1">v{{ x.v }} <span class="dim">· {{ datum(x.angelegt) }}</span></span>
</div> </div>
@@ -158,8 +179,8 @@ function tokens(n) { return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? M
<button class="burger sekundaer klein" title="Themen" @click="navAuf = !navAuf">☰</button> <button class="burger sekundaer klein" title="Themen" @click="navAuf = !navAuf">☰</button>
<span class="titel">{{ aktuell?.titel || '' }}</span> <span class="titel">{{ aktuell?.titel || '' }}</span>
<div class="tabs"> <div class="tabs">
<button :class="{ aktiv: tab === 'lauf' }" @click="tab = 'lauf'">Lauf</button> <button :class="{ aktiv: tab === 'lauf' }" @click="geheZu({ tab: 'lauf' })">Lauf</button>
<button :class="{ aktiv: tab === 'buch' }" @click="tab = 'buch'">Buch</button> <button :class="{ aktiv: tab !== 'lauf' }" @click="geheZu({ tab: 'buch' })">Buch</button>
</div> </div>
<span v-if="state" class="badge" :class="statusBadge[0]">{{ statusBadge[1] }}</span> <span v-if="state" class="badge" :class="statusBadge[0]">{{ statusBadge[1] }}</span>
<span v-if="tab === 'lauf' && state?.tokens" class="badge">{{ (state.kosten_usd || 0).toFixed(2) }} $ · {{ tokens(state.tokens) }} Tokens</span> <span v-if="tab === 'lauf' && state?.tokens" class="badge">{{ (state.kosten_usd || 0).toFixed(2) }} $ · {{ tokens(state.tokens) }} Tokens</span>
@@ -174,11 +195,13 @@ function tokens(n) { return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? M
<div v-if="fehler" class="fehler" @click="fehler = ''">{{ fehler }} — klicken zum Schließen</div> <div v-if="fehler" class="fehler" @click="fehler = ''">{{ fehler }} — klicken zum Schließen</div>
<main class="inhalt" id="inhalt" tabindex="-1"> <main class="inhalt" id="inhalt" tabindex="-1">
<Lauf v-if="tab === 'lauf' && state" :state="state" <Lauf v-if="tab === 'lauf' && state" :state="state"
@start="aktion(() => api.start(topic, version))" @start="aktion(() => api.start(slug, versionAktiv))"
@pause="aktion(() => api.pause(topic, version))" @pause="aktion(() => api.pause(slug, versionAktiv))"
@reset="aktion(() => api.reset(topic, version))" @reset="aktion(() => api.reset(slug, versionAktiv))"
@neue-version="neueVersion" /> @neue-version="neueVersion" />
<Guide v-else-if="tab === 'buch' && topic" :topic="topic" :version="version" :laeuft="laeuft" :fortschritt="fortschritt" /> <Guide v-else-if="tab !== 'lauf' && slug && versionAktiv" :slug="slug" :version="versionAktiv" :tab="tab" :nr="nr"
:laeuft="laeuft" :fortschritt="fortschritt"
@tab="(t) => geheZu({ tab: t })" @nr="(e) => geheZu({ tab: 'buch', nr: e.nr }, !e.push)" />
<div v-else style="padding: 24px; color: var(--dim)">Thema links anlegen oder wählen.</div> <div v-else style="padding: 24px; color: var(--dim)">Thema links anlegen oder wählen.</div>
</main> </main>
</div> </div>

View File

@@ -13,20 +13,22 @@ async function req(pfad, opts = {}) {
return ct.includes('json') ? r.json() : r.text() return ct.includes('json') ? r.json() : r.text()
} }
// Pfade: /api/themen/<slug>/v<N>/… – die Version ist immer Teil der Adresse
const e = encodeURIComponent const e = encodeURIComponent
const q = (v) => (v ? `?v=${v}` : '') // Version; ohne Angabe die neueste const v = (slug, n) => `/themen/${e(slug)}/v${n}`
export const api = { export const api = {
topics: () => req('/topics'), themen: () => req('/themen'),
anlegen: (name, hinweise = '') => req('/topics', { method: 'POST', body: { name, hinweise } }), anlegen: (name, hinweise = '') => req('/themen', { method: 'POST', body: { name, hinweise } }),
versionAnlegen: (t) => req(`/topics/${e(t)}/version`, { method: 'POST' }), loeschen: (slug) => req(`/themen/${e(slug)}`, { method: 'DELETE' }),
loeschen: (t, v) => req(`/topics/${e(t)}${q(v)}`, { method: 'DELETE' }), versionAnlegen: (slug) => req(`/themen/${e(slug)}/versionen`, { method: 'POST' }),
start: (t, v) => req(`/topics/${e(t)}/start${q(v)}`, { method: 'POST' }), versionLoeschen: (slug, n) => req(v(slug, n), { method: 'DELETE' }),
pause: (t, v) => req(`/topics/${e(t)}/pause${q(v)}`, { method: 'POST' }), state: (slug, n) => req(v(slug, n)),
reset: (t, v) => req(`/topics/${e(t)}/reset${q(v)}`, { method: 'POST' }), start: (slug, n) => req(v(slug, n) + '/start', { method: 'POST' }),
state: (t, v) => req(`/topics/${e(t)}/state${q(v)}`), pause: (slug, n) => req(v(slug, n) + '/pause', { method: 'POST' }),
guide: (t, v) => req(`/topics/${e(t)}/guide${q(v)}`), reset: (slug, n) => req(v(slug, n) + '/reset', { method: 'POST' }),
position: (t, v) => req(`/topics/${e(t)}/position${q(v)}`), buch: (slug, n) => req(v(slug, n) + '/buch'),
positionSetzen: (t, daten, v) => req(`/topics/${e(t)}/position${q(v)}`, { method: 'PUT', body: daten }), position: (slug, n) => req(v(slug, n) + '/position'),
guideMdUrl: (t, v) => `/api/topics/${e(t)}/guide.md${q(v)}`, positionSetzen: (slug, n, daten) => req(v(slug, n) + '/position', { method: 'PUT', body: daten }),
pdfUrl: (t, v) => `/api/topics/${e(t)}/buch.pdf${q(v)}`, buchMdUrl: (slug, n) => '/api' + v(slug, n) + '/buch.md',
pdfUrl: (slug, n) => '/api' + v(slug, n) + '/buch.pdf',
} }

View File

@@ -4,16 +4,38 @@ import { api } from '../api.js'
import { render, zeit } from '../markdown.js' import { render, zeit } from '../markdown.js'
import Lesemodus from './Lesemodus.vue' import Lesemodus from './Lesemodus.vue'
const props = defineProps({ topic: String, version: Number, laeuft: Boolean, fortschritt: Number }) const props = defineProps({ slug: String, version: Number, tab: String, nr: String, laeuft: Boolean, fortschritt: Number })
const emit = defineEmits(['tab', 'nr'])
const daten = ref(null) const daten = ref(null)
const lesemodus = ref(false) const lesemodus = computed(() => props.tab === 'lesen')
// Abschnittsnummer in der Adresse (3.4, 3, vorwort) ↔ Element-ID (a03-4, k03, vorwort)
function idVonNr(nr) {
if (!nr || !daten.value) return ''
if (nr === 'vorwort') return 'vorwort'
for (const k of daten.value.kapitel) {
if (String(k.ord) === nr) return 'k' + k.id
const a = k.abschnitte.find((x) => x.nr === nr)
if (a) return 'a' + a.id
}
return ''
}
function nrVonId(id) {
if (!id || !daten.value) return ''
if (id === 'vorwort') return 'vorwort'
for (const k of daten.value.kapitel) {
if ('k' + k.id === id) return String(k.ord)
const a = k.abschnitte.find((x) => 'a' + x.id === id)
if (a) return a.nr
}
return ''
}
// Vollbild nur aus der Klickgeste heraus (Browser-Vorgabe); iOS-Safari kennt die API nicht. // Vollbild nur aus der Klickgeste heraus (Browser-Vorgabe); iOS-Safari kennt die API nicht.
function lesemodusStart() { function lesemodusStart() {
lesemodus.value = true emit('tab', 'lesen')
document.documentElement.requestFullscreen?.().catch(() => {}) document.documentElement.requestFullscreen?.().catch(() => {})
} }
function lesemodusEnde() { function lesemodusEnde() {
lesemodus.value = false emit('tab', 'buch')
if (document.fullscreenElement) document.exitFullscreen().catch(() => {}) if (document.fullscreenElement) document.exitFullscreen().catch(() => {})
} }
const navAuf = ref(false) const navAuf = ref(false)
@@ -39,25 +61,27 @@ let ladeSeq = 0
let merkTimer = null let merkTimer = null
function merken(id) { function merken(id) {
clearTimeout(merkTimer) clearTimeout(merkTimer)
merkTimer = setTimeout(() => api.positionSetzen(props.topic, { abschnitt: id }, props.version).catch(() => {}), 1500) merkTimer = setTimeout(() => api.positionSetzen(props.slug, props.version, { abschnitt: id }).catch(() => {}), 1500)
} }
const cache = new Map() // Schlüssel: Abschnitt + Textlänge → unveränderte Abschnitte werden nicht neu gerendert const cache = new Map() // Schlüssel: Abschnitt + Textlänge → unveränderte Abschnitte werden nicht neu gerendert
onUnmounted(() => { beobachter?.disconnect() }) onUnmounted(() => { beobachter?.disconnect() })
async function laden() { async function laden() {
if (!props.topic) { daten.value = null; return } if (!props.slug || !props.version) { daten.value = null; return }
const seq = ++ladeSeq const seq = ++ladeSeq
const [d, pos] = await Promise.all([api.guide(props.topic, props.version), api.position(props.topic, props.version).catch(() => ({}))]) const [d, pos] = await Promise.all([api.buch(props.slug, props.version), api.position(props.slug, props.version).catch(() => ({}))])
if (seq !== ladeSeq) return if (seq !== ladeSeq) return
daten.value = d daten.value = d
await nextTick() await nextTick()
spy() spy()
// Deep-Link (#a03-1 / #k03) hat Vorrang vor der gemerkten Position; beides nur beim ersten Laden // Abschnitt in der Adresse hat Vorrang vor der gemerkten Position; beides nur beim ersten Laden
const ziel = location.hash.slice(1) || pos.abschnitt const ziel = idVonNr(props.nr) || pos.abschnitt
if (ziel && !aktiv.value) document.getElementById(ziel)?.scrollIntoView({ block: 'start' }) if (ziel && !aktiv.value) document.getElementById(ziel)?.scrollIntoView({ block: 'start' })
} }
watch(() => [props.topic, props.version], () => { aktiv.value = ''; cache.clear(); laden() }, { immediate: true }) watch(() => [props.slug, props.version], () => { aktiv.value = ''; cache.clear(); laden() }, { immediate: true })
// Zurück-Taste oder getippte Adresse: zum Abschnitt springen, wenn er nicht schon aktiv ist
watch(() => props.nr, (n) => { const id = idVonNr(n); if (id && id !== aktiv.value) { aktiv.value = id; document.getElementById(id)?.scrollIntoView({ block: 'start' }) } })
watch(() => props.laeuft, (l, alt) => { if (alt && !l) laden() }) watch(() => props.laeuft, (l, alt) => { if (alt && !l) laden() })
// Während eines Laufs nur nachladen, wenn sich der Fortschritt geändert hat // Während eines Laufs nur nachladen, wenn sich der Fortschritt geändert hat
watch(() => props.fortschritt, (n, alt) => { if (props.laeuft && n !== alt) laden() }) watch(() => props.fortschritt, (n, alt) => { if (props.laeuft && n !== alt) laden() })
@@ -94,7 +118,7 @@ const kapBadge = { geschrieben: ['gelb', 'geschrieben'], geprueft: ['blau', 'gep
function spy() { function spy() {
beobachter?.disconnect() beobachter?.disconnect()
beobachter = new IntersectionObserver((es) => { beobachter = new IntersectionObserver((es) => {
for (const e of es) if (e.isIntersecting) { aktiv.value = e.target.id; merken(e.target.id) } for (const e of es) if (e.isIntersecting) { aktiv.value = e.target.id; merken(e.target.id); emit('nr', { nr: nrVonId(e.target.id), push: false }) }
}, { root: lese.value, rootMargin: '0px 0px -75% 0px' }) }, { root: lese.value, rootMargin: '0px 0px -75% 0px' })
document.querySelectorAll('.buch [data-spy]').forEach((el) => beobachter.observe(el)) document.querySelectorAll('.buch [data-spy]').forEach((el) => beobachter.observe(el))
} }
@@ -102,7 +126,7 @@ function springe(id) {
navAuf.value = false navAuf.value = false
aktiv.value = id aktiv.value = id
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }) document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
history.replaceState(null, '', location.pathname + location.search + '#' + id) emit('nr', { nr: nrVonId(id), push: true })
} }
function taste(e) { if (e.key === 'Escape') navAuf.value = false } function taste(e) { if (e.key === 'Escape') navAuf.value = false }
onMounted(() => window.addEventListener('keydown', taste)) onMounted(() => window.addEventListener('keydown', taste))
@@ -116,8 +140,8 @@ onUnmounted(() => window.removeEventListener('keydown', taste))
<button class="schliessen sekundaer klein" @click="navAuf = false">✕ Schließen</button> <button class="schliessen sekundaer klein" @click="navAuf = false">✕ Schließen</button>
<div class="werkzeuge"> <div class="werkzeuge">
<button class="klein" title="Lesemodus: Abschnitt für Abschnitt" @click="lesemodusStart" :disabled="!abschnitte.length">Lesen ▶</button> <button class="klein" title="Lesemodus: Abschnitt für Abschnitt" @click="lesemodusStart" :disabled="!abschnitte.length">Lesen ▶</button>
<a class="knopf klein sekundaer" title="Als Markdown-Datei herunterladen" :href="api.guideMdUrl(topic, version)" download>MD</a> <a class="knopf klein sekundaer" title="Als Markdown-Datei herunterladen" :href="api.buchMdUrl(slug, version)" download>MD</a>
<a v-if="daten?.pdf" class="knopf klein sekundaer" title="Als PDF herunterladen" :href="api.pdfUrl(topic, version)" download>PDF</a> <a v-if="daten?.pdf" class="knopf klein sekundaer" title="Als PDF herunterladen" :href="api.pdfUrl(slug, version)" download>PDF</a>
</div> </div>
<template v-if="daten"> <template v-if="daten">
<div class="meta"> <div class="meta">
@@ -142,8 +166,8 @@ onUnmounted(() => window.removeEventListener('keydown', taste))
<div class="mobil-leiste"> <div class="mobil-leiste">
<button class="sekundaer klein" @click="navAuf = !navAuf">☰ Inhalt</button> <button class="sekundaer klein" @click="navAuf = !navAuf">☰ Inhalt</button>
<button class="klein" @click="lesemodusStart" :disabled="!abschnitte.length">Lesen ▶</button> <button class="klein" @click="lesemodusStart" :disabled="!abschnitte.length">Lesen ▶</button>
<a class="knopf klein sekundaer" :href="api.guideMdUrl(topic, version)" download>MD</a> <a class="knopf klein sekundaer" :href="api.buchMdUrl(slug, version)" download>MD</a>
<a v-if="daten?.pdf" class="knopf klein sekundaer" :href="api.pdfUrl(topic, version)" download>PDF</a> <a v-if="daten?.pdf" class="knopf klein sekundaer" :href="api.pdfUrl(slug, version)" download>PDF</a>
</div> </div>
<article v-if="daten" class="buch"> <article v-if="daten" class="buch">
<h1>{{ daten.titel }}</h1> <h1>{{ daten.titel }}</h1>
@@ -190,6 +214,6 @@ onUnmounted(() => window.removeEventListener('keydown', taste))
</article> </article>
</div> </div>
<Lesemodus v-if="lesemodus && daten" :daten="daten" :topic="topic" :version="version" @schliessen="lesemodusEnde" /> <Lesemodus v-if="lesemodus && daten" :daten="daten" :slug="slug" :version="version" @schliessen="lesemodusEnde" />
</div> </div>
</template> </template>

View File

@@ -3,7 +3,7 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { render, zeit } from '../markdown.js' import { render, zeit } from '../markdown.js'
import { api } from '../api.js' import { api } from '../api.js'
const props = defineProps({ daten: Object, topic: String, version: Number }) const props = defineProps({ daten: Object, slug: String, version: Number })
const emit = defineEmits(['schliessen']) const emit = defineEmits(['schliessen'])
const buehne = ref(null) const buehne = ref(null)
const pos = ref(0) const pos = ref(0)
@@ -53,7 +53,7 @@ watch(pos, (p) => {
buehne.value?.scrollTo({ top: 0 }) buehne.value?.scrollTo({ top: 0 })
clearTimeout(merkTimer) clearTimeout(merkTimer)
const key = screens.value[p]?.key const key = screens.value[p]?.key
if (key) merkTimer = setTimeout(() => api.positionSetzen(props.topic, { lesemodus: key }, props.version).catch(() => {}), 1500) if (key) merkTimer = setTimeout(() => api.positionSetzen(props.slug, props.version, { lesemodus: key }).catch(() => {}), 1500)
}) })
function taste(e) { function taste(e) {
@@ -82,7 +82,7 @@ function wischEnde(e) {
} }
onMounted(async () => { onMounted(async () => {
window.addEventListener('keydown', taste) window.addEventListener('keydown', taste)
const merk = (await api.position(props.topic, props.version).catch(() => ({}))).lesemodus const merk = (await api.position(props.slug, props.version).catch(() => ({}))).lesemodus
// Alte Positionen waren ein Index, neue sind ein key. // Alte Positionen waren ein Index, neue sind ein key.
const i = typeof merk === 'string' ? screens.value.findIndex((s) => s.key === merk) : Number(merk || 0) const i = typeof merk === 'string' ? screens.value.findIndex((s) => s.key === merk) : Number(merk || 0)
pos.value = Math.min(Math.max(i, 0), screens.value.length - 1) pos.value = Math.min(Math.max(i, 0), screens.value.length - 1)