diff --git a/API.md b/API.md index 234cb08..650fae8 100644 --- a/API.md +++ b/API.md @@ -2,24 +2,36 @@ Backend: FastAPI auf Port 8020, liefert `frontend/dist/` aus. Alle Pfade unter `/api`. -## Themen +## Adressen -- `GET /api/topics` → `[{name, titel, status, laeuft, stufe}]` - - `status`: `neu | laeuft | pausiert | fertig | fehler` -- `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}` -- `POST /api/topics/{name}/start` – startet oder setzt fort (resümiert an der Stufe, die noch offen ist) -- `POST /api/topics/{name}/pause` – bricht laufende Agenten ab; Neustart mit `start` -- `POST /api/topics/{name}/reset` – löscht alles außer `thema.json` +Ein Thema hat einen Slug („Godot 4“ → `godot-4`) und Versionen `v1`, `v2`, … mit eigenem Lauf, Buch, Quellen und Leseposition. +Die Version ist in der API immer Teil des Pfads; „neueste“ löst die Oberfläche selbst auf. + +Seite: `/`, `//v2/lauf`, `//v2/buch`, `//v2/buch/3.4` (Abschnitt; `3` = Kapitel, `vorwort`), `//v2/lesen`. +Ohne `v2` gilt die neueste Version. + +## 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 -`GET /api/topics/{name}/state` → +`GET /api/themen/{slug}/v{N}` → ```json { - "name": "Godot 4", "titel": "Godot 4", "hinweise": "…", - "status": "laeuft", "grund": "", "stufe": "schreiben", + "slug": "godot-4", "name": "Godot 4", "version": 1, "titel": "Godot 4", "hinweise": "…", + "status": "laeuft", "grund": "", "stufe": "schreiben", "angelegt": "2026-09-28T10:00:00Z", "deutung": {"titel": "Godot 4", "untertitel": "…", "deutung": "…", "buchtyp": "werkzeug|wissen", "zielgruppe": "…"}, "stufen": [ {"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, "agenten": [ - {"id": "schreiben-03", "stufe": "schreiben", "name": "Kapitel 3 schreiben", "status": "laeuft|fertig|fehler|abgebrochen", - "kosten_usd": 0.9, "tokens": 180000, "werkzeuge": 41, "gestartet": "…", "beendet": "…", "bericht": ""} + {"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": ""} ], - "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 -`GET /api/topics/{name}/guide` → +`GET /api/themen/{slug}/v{N}/buch` → ```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/topics/{name}/buch.pdf` – PDF (404, solange nicht gebaut) +- `GET /api/themen/{slug}/v{N}/buch.md` – ganzes Buch als Markdown +- `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 ``` -storage// - thema.json {name, titel, hinweise, angelegt} - state.json Zustand wie oben (ohne guide) - deutung.json vom Recherche-Agenten - quellen/ QUELLEN.md + geklonte/geladene Quellen - buch/ STIL.md, GLIEDERUNG.md, 00-vorwort.md, NN-*.md, README.md, buch.md, buch.pdf - berichte/ .md (Abschlussberichte) - log.jsonl Ereignisse +storage// + thema.json {name, slug, titel, hinweise, angelegt} + v1/, v2/, … je Version: + state.json Zustand wie oben (ohne Buch) + deutung.json vom Recherche-Agenten + position.json Leseposition + quellen/ QUELLEN.md + geklonte/geladene Quellen + buch/ STIL.md, GLIEDERUNG.md, 00-vorwort.md, NN-*.md, README.md, buch.md, buch.pdf + berichte/ .md (Abschlussberichte) + arbeit/ Arbeitsdateien der Agenten + log.jsonl Ereignisse ``` -## Versionen - -Ein Thema kann mehrere Versionen haben (gleiche Eingaben, unabhängiger Lauf, eigenes Buch und eigene Leseposition). -Ablage: `storage//thema.json` und `storage//v/…`. 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. +Alte Ablagen (alles direkt unter `storage//`) werden beim Start nach `v1/` verschoben, fehlende Slugs nachgetragen. diff --git a/Makefile b/Makefile index 57a5665..37e3a8d 100644 --- a/Makefile +++ b/Makefile @@ -55,6 +55,7 @@ pull: loeschen: @[ -n "$(TOPIC)" ] || { echo "TOPIC= angeben (optional V=)"; 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" ] - 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),)" @echo "Gelöscht: $(TOPIC)$(if $(V), v$(V),)" diff --git a/backend/main.py b/backend/main.py index ad8054b..d70f2de 100644 --- a/backend/main.py +++ b/backend/main.py @@ -36,11 +36,18 @@ class Position(BaseModel): lesemodus: str | int | None = None # neu: key des Bildschirms, alt: Index -def _key(name: str, v: int | None = None) -> str: - """Themenname + optionale Version → Schlüssel „Thema/vN“ (ohne v: neueste).""" - if not state.name_ok(name) or not state.thema(name): +def _thema(slug: str) -> dict: + name = state.name_von_slug(slug) + t = state.thema(name) if name else None + if not t: 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: raise HTTPException(404, "Version unbekannt") return key @@ -54,111 +61,120 @@ def _version_info(name: str, n: int) -> dict: "angelegt": angelegt, "kosten_usd": st.get("kosten_usd", 0)} -@app.get("/api/topics") -async def topics(): - aus = [] - for t in state.themen(): - vs = [_version_info(t["name"], n) for n in state.versionen(t["name"])] - if not vs: - continue - neueste = vs[-1] - aus.append({"name": t["name"], "titel": t["titel"], "status": neueste["status"], - "laeuft": neueste["laeuft"], "stufe": neueste["stufe"], "versionen": vs}) - return aus +def _thema_info(t: dict) -> dict | None: + vs = [_version_info(t["name"], n) for n in state.versionen(t["name"])] + if not vs: + return None + neueste = vs[-1] + return {"slug": t["slug"], "name": t["name"], "titel": t["titel"], "hinweise": t.get("hinweise", ""), + "angelegt": t.get("angelegt", ""), "status": neueste["status"], "laeuft": neueste["laeuft"], + "stufe": neueste["stufe"], "versionen": vs} -@app.post("/api/topics") -async def topic_anlegen(body: ThemaNeu): +@app.get("/api/themen") +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() if not state.name_ok(name): raise HTTPException(400, "Ungültiger Name") try: - return state.anlegen(name, body.hinweise) + return _thema_info(state.anlegen(name, body.hinweise)) except ValueError as e: raise HTTPException(409, str(e)) -@app.post("/api/topics/{name}/version") -async def version_anlegen(name: str): - """Neue Version: gleiche Eingaben, frischer Lauf, ohne Wissen über die Vorgänger.""" - _key(name) - key = state.version_anlegen(name) - return {"v": int(key.rsplit("/v", 1)[1])} +@app.get("/api/themen/{slug}") +async def thema(slug: str): + return _thema_info(_thema(slug)) -@app.delete("/api/topics/{name}") -async def topic_loeschen(name: str, v: int | None = None): - if v is None: - _key(name) - for n in state.versionen(name): - await pipeline.pausieren(f"{name}/v{n}") - state.loeschen(name) - else: - key = _key(name, v) - await pipeline.pausieren(key) - state.version_loeschen(key) +@app.delete("/api/themen/{slug}") +async def thema_loeschen(slug: str): + t = _thema(slug) + for n in state.versionen(t["name"]): + await pipeline.pausieren(f"{t['name']}/v{n}") + state.loeschen(t["name"]) return {"ok": True} -@app.post("/api/topics/{name}/start") -async def start(name: str, v: int | None = None): - key = _key(name, v) +@app.post("/api/themen/{slug}/versionen") +async def version_anlegen(slug: str): + """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": raise HTTPException(400, "Lauf ist fertig – erst zurücksetzen") pipeline.starten(key) return {"ok": True} -@app.post("/api/topics/{name}/pause") -async def pause(name: str, v: int | None = None): - await pipeline.pausieren(_key(name, v)) +@app.post("/api/themen/{slug}/v{n}/pause") +async def pause(slug: str, n: int): + await pipeline.pausieren(_key(slug, n)) return {"ok": True} -@app.post("/api/topics/{name}/reset") -async def reset(name: str, v: int | None = None): - key = _key(name, v) +@app.post("/api/themen/{slug}/v{n}/reset") +async def reset(slug: str, n: int): + key = _key(slug, n) await pipeline.pausieren(key) state.zuruecksetzen(key) return {"ok": True} -@app.get("/api/topics/{name}/state") -async def zustand(name: str, v: int | None = None): - key = _key(name, v) - 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/themen/{slug}/v{n}/position") +async def position_lesen(slug: str, n: int): + return state.position(_key(slug, n)) -@app.get("/api/topics/{name}/position") -async def position_lesen(name: str, v: int | None = None): - return state.position(_key(name, v)) +@app.put("/api/themen/{slug}/v{n}/position") +async def position_schreiben(slug: str, n: int, body: Position): + 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") -async def position_schreiben(name: str, body: Position, v: int | None = None): - return state.position_setzen(_key(name, v), {k: val for k, val in body.model_dump().items() if val is not None}) +@app.get("/api/themen/{slug}/v{n}/buch") +async def buch(slug: str, n: int): + return montage.guide(_key(slug, n)) -@app.get("/api/topics/{name}/guide") -async def guide(name: str, v: int | None = None): - return montage.guide(_key(name, v)) +@app.get("/api/themen/{slug}/v{n}/buch.md", response_class=PlainTextResponse) +async def buch_md(slug: str, n: int): + return montage.markdown(_key(slug, n)) -@app.get("/api/topics/{name}/guide.md", response_class=PlainTextResponse) -async def guide_md(name: str, v: int | None = None): - return montage.markdown(_key(name, v)) - - -@app.get("/api/topics/{name}/buch.pdf") -async def buch_pdf(name: str, v: int | None = None): - key = _key(name, v) +@app.get("/api/themen/{slug}/v{n}/buch.pdf") +async def buch_pdf(slug: str, n: int): + key = _key(slug, n) p = state.verzeichnis(key) / "buch" / "buch.pdf" if not p.exists(): 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(): @@ -166,4 +182,6 @@ if config.FRONTEND_DIST.exists(): @app.get("/{pfad:path}") async def spa(pfad: str): + if pfad.startswith("api/"): + raise HTTPException(404, "Unbekannter API-Pfad") return FileResponse(config.FRONTEND_DIST / "index.html") diff --git a/backend/state.py b/backend/state.py index 251757d..452f9d4 100644 --- a/backend/state.py +++ b/backend/state.py @@ -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}" +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: n = (versionen(name) or [0])[-1] + 1 key = f"{name}/v{n}" @@ -60,8 +74,12 @@ def version_loeschen(key: str) -> None: def migrieren() -> None: - """Alte Ablage (alles direkt unter storage//) einmalig nach v1/ verschieben.""" + """Alte Ablage (alles direkt unter storage//) einmalig nach v1/ verschieben; Slugs nachtragen.""" 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(): continue v1 = d / "v1" @@ -106,7 +124,7 @@ def anlegen(name: str, hinweise: str) -> dict: if d.exists(): raise ValueError("Thema existiert schon") 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) version_anlegen(name) return t @@ -213,7 +231,7 @@ def slug(text: str) -> str: for a, b in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")): t = t.replace(a, b) t = re.sub(r"[^a-z0-9]+", "-", t).strip("-") - return t[:60] or "kapitel" + return t[:60] or "thema" def position(name: str) -> dict: diff --git a/frontend/src/App.vue b/frontend/src/App.vue index b69b0c6..67d5c29 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -4,11 +4,32 @@ import { api } from './api.js' import Lauf from './components/Lauf.vue' import Guide from './components/Guide.vue' -const params = new URLSearchParams(location.search) +// Adresse: /[/v][/lauf|/buch|/lesen][/] – 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 topic = ref(params.get('topic') || localStorage.getItem('topic') || '') -const version = ref(Number(params.get('v')) || null) // null = neueste Version -const tab = ref((params.get('tab') || localStorage.getItem('tab')) === 'buch' ? 'buch' : 'lauf') +const slug = ref(start.slug || localStorage.getItem('slug') || '') +const version = ref(start.v) // null = neueste Version +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 state = ref(null) const fehler = ref('') @@ -27,38 +48,37 @@ watch(theme, themeAnwenden) media.addEventListener('change', themeAnwenden) themeAnwenden() -// URL spiegelt Thema und Tab; beim Themenwechsel fällt der Deep-Link (#a…/#k…) weg -function urlSchreiben(hashBehalten) { - const p = new URLSearchParams() - if (topic.value) p.set('topic', topic.value) - if (version.value) p.set('v', version.value) - p.set('tab', tab.value) - history.replaceState(null, '', location.pathname + '?' + p + (hashBehalten ? location.hash : '')) +// Navigation: pushState für Klicks (Zurück-Taste funktioniert), replaceState beim Scrollen +function geheZu(r, ersetzen = false) { + const ziel = { slug: slug.value, v: version.value, tab: tab.value, nr: nr.value, ...r } + if (ziel.tab === 'lauf') ziel.nr = '' + const pfad = pfadVon(ziel) + if (pfad !== location.pathname) history[ersetzen ? 'replaceState' : 'pushState'](null, '', pfad) + 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) }) -watch(version, () => urlSchreiben(false)) -watch(tab, (t) => { localStorage.setItem('tab', t); urlSchreiben(true) }) +function popstate() { const r = routeLesen(); slug.value = r.slug; version.value = r.v; tab.value = r.tab; nr.value = r.nr } +watch(slug, (sl) => { if (sl) localStorage.setItem('slug', sl) }) -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 neueste = computed(() => versionen.value.length ? versionen.value[versionen.value.length - 1].v : null) const versionAktiv = computed(() => version.value || neueste.value) 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 }) 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 versionWaehlen(name, v) { topic.value = name; version.value = v; tab.value = 'buch'; navAuf.value = false } +function themaWaehlen(sl) { geheZu({ slug: sl, v: null, tab: 'lauf' }); 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 fortschritt = computed(() => (state.value?.stufen || []).reduce((n, s) => n + (s.fertig || 0), 0)) async function topicsLaden() { try { - topics.value = await api.topics() + topics.value = await api.themen() 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 (!topic.value && topics.value.length) topic.value = topics.value[0].name } catch (e) { // Server gerade nicht erreichbar (z. B. Neustart): alle 3 s erneut versuchen, bis er antwortet fehler.value = 'Server nicht erreichbar — versuche es weiter …' @@ -66,53 +86,55 @@ async function topicsLaden() { } } async function stateLaden() { - if (!topic.value) { state.value = null; return } - try { state.value = await api.state(topic.value, version.value) } catch (e) { fehler.value = String(e.message || e) } + if (!slug.value || !versionAktiv.value) { state.value = null; return } + try { state.value = await api.state(slug.value, versionAktiv.value) } catch (e) { fehler.value = String(e.message || e) } } function pollen() { clearInterval(timer) timer = setInterval(async () => { - if (!topic.value) return + if (!slug.value) return await stateLaden() if (!laeuft.value) { clearInterval(timer); timer = null; await topicsLaden() } }, 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() }) async function anlegen() { const name = neuName.value.trim() if (!name) return try { - await api.anlegen(name, neuHinweise.value.trim()) + const t = await api.anlegen(name, neuHinweise.value.trim()) neuName.value = '' neuHinweise.value = '' await topicsLaden() - themaWaehlen(name) + themaWaehlen(t.slug) } catch (e) { fehler.value = String(e.message || e) } } // Neue Version: gleiche Eingaben, frischer Lauf; die alte Version bleibt samt Leseposition erhalten. async function neueVersion() { try { - const r = await api.versionAnlegen(topic.value) + const r = await api.versionAnlegen(slug.value) await topicsLaden() - version.value = r.v - tab.value = 'lauf' - await aktion(() => api.start(topic.value, r.v)) + geheZu({ v: r.v, tab: 'lauf' }) + await aktion(() => api.start(slug.value, r.v)) } catch (e) { fehler.value = String(e.message || e) } } async function aktion(fn) { try { await fn(); await stateLaden(); await topicsLaden(); if (laeuft.value) pollen() } catch (e) { fehler.value = String(e.message || e) } } -async function loeschen(name) { - if (!confirm(`Thema „${name}“ löschen?`)) return - await aktion(() => api.loeschen(name)) +async function loeschen(t) { + if (!confirm(`Thema „${t.titel}“ löschen?`)) return + await aktion(() => api.loeschen(t.slug)) } 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() }) -onUnmounted(() => { clearInterval(timer); window.removeEventListener('keydown', esc) }) +onMounted(async () => { + 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 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) } -