diff --git a/README.md b/README.md index 366fd2a..0171062 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,23 @@ Oben im Bedienfeld wechselt man zwischen ihnen. **Jedes Projekt speichert seinen Stand** — Einstellungen, Layouts, Muster und alle Verschiebungen aus dem Canvas überleben einen Reload. „Projekt zurücksetzen" wirft ihn weg. +### Der Stand liegt im Repo + +Nicht nur im Browser: jede Änderung landet auch in `state/.json`. +**Diese Dateien gehören mit in den Commit.** Nur so hat die andere Maschine +nach `git pull` denselben Stand. + +``` +state/post1.json Post 1 · Lösung +state/post1-alt.json Post 1 · Alt +state/post2.json Post 2 · Statement +``` + +Beim Laden gewinnt die Datei, der localStorage ist nur der Rückfall, wenn der +Server nicht läuft. Wer den Stand ändert, muss also committen und pushen — +sonst sieht ihn niemand sonst. Gibt es die Datei noch nicht, wird der Stand +aus dem Browser einmalig hineingeschrieben. + ## Bedienung | Taste | Wirkung | @@ -52,7 +69,8 @@ assets/ ├── screens/ Software-Screenshots └── fonts/ Manrope 400–800, lokal vendor/ html-to-image, JSZip -serve.py Statischer Server + /save-Endpunkt +state/ Projektstände, gehören ins Repo +serve.py Statischer Server + /save- und /state-Endpunkt rules.md Gestaltungsregeln, Farbwerte, Export-Wissen ``` diff --git a/index.html b/index.html index b40e529..df3ac5d 100644 --- a/index.html +++ b/index.html @@ -352,7 +352,9 @@ /* ─── Projektstand ──────────────────────────────────────────────────────── Gleiches Prinzip wie bei Post 1: Das Werkzeug hält den Stand. */ - const STORE = "hivegrid.projekt.post2"; + const PROJECT = "post2"; + const STORE = "hivegrid.projekt." + PROJECT; + const STATE_URL = "./state/" + PROJECT + ".json"; let savedTimer = null; function markSaved() { @@ -364,23 +366,65 @@ savedTimer = setTimeout(() => el.classList.remove("on"), 1200); } - function saveState() { - try { - localStorage.setItem(STORE, JSON.stringify({ - v: 1, - variant: slide.dataset.variant, - pattern: slide.dataset.pattern, - frame: slide.dataset.frame, - density: currentDensity, - })); - markSaved(); - } catch (err) { console.warn("Speichern fehlgeschlagen", err); } + function currentState() { + return { + v: 1, + variant: slide.dataset.variant, + pattern: slide.dataset.pattern, + frame: slide.dataset.frame, + density: currentDensity, + }; } - function loadState() { - let d = null; - try { d = JSON.parse(localStorage.getItem(STORE) || "null"); } catch (_) {} + /* Der Stand geht in zwei Ablagen. Sofort in den localStorage, damit die + Rückmeldung nicht auf das Netz wartet, und gebündelt in + state/.json. Nur die Datei lässt sich pushen, also ist sie die + Wahrheit; der localStorage ist der Rückfall, wenn der Server nicht läuft. */ + let pushTimer = null; + function saveState() { + const state = currentState(); + try { localStorage.setItem(STORE, JSON.stringify(state)); } + catch (err) { console.warn("Speichern fehlgeschlagen", err); } + markSaved(); + clearTimeout(pushTimer); + pushTimer = setTimeout(() => pushState(state), 600); + } + + async function pushState(state) { + pushTimer = null; + try { + const res = await fetch("/state?project=" + PROJECT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(state), + }); + if (!res.ok) throw new Error("HTTP " + res.status); + } catch (err) { console.warn("Stand nicht in die Datei geschrieben", err); } + } + + // Ein Reload darf keine Änderung verschlucken, die noch im Timer hängt. + addEventListener("beforeunload", () => { + if (!pushTimer) return; + clearTimeout(pushTimer); + navigator.sendBeacon("/state?project=" + PROJECT, + new Blob([JSON.stringify(currentState())], { type: "application/json" })); + }); + + async function loadState() { + let d = null, fromFile = false; + // Erst die Datei aus dem Repo, dann der Browser. Wer das Projekt frisch + // gezogen hat, soll den gepushten Stand sehen und nicht seinen eigenen alten. + try { + const res = await fetch(STATE_URL, { cache: "no-store" }); + if (res.ok) { d = await res.json(); fromFile = true; } + } catch (_) {} + if (!d) { + try { d = JSON.parse(localStorage.getItem(STORE) || "null"); } catch (_) {} + } if (!d || d.v !== 1) return false; + // Stand aus dem Browser, aber noch keine Datei: einmalig hinueberschreiben, + // sonst bliebe ein ueber Wochen gewachsener Stand ungeteilt liegen. + if (!fromFile) setTimeout(() => pushState(currentState()), 0); if (d.variant) setVariant(d.variant); if (d.pattern) setPattern(d.pattern); if (d.frame) setFrame(d.frame); @@ -389,8 +433,13 @@ } function forgetProject() { + clearTimeout(pushTimer); pushTimer = null; try { localStorage.removeItem(STORE); } catch (_) {} - location.reload(); + // Auch die Datei muss weg, sonst spielt sie den alten Stand beim nächsten + // Laden wieder ein. + fetch("/state?project=" + PROJECT, { method: "DELETE" }) + .catch(() => {}) + .then(() => location.reload()); } function setStatus(msg, kind) { @@ -560,7 +609,7 @@ (async function boot() { try { buildPattern(0.55); - const restored = loadState(); + const restored = await loadState(); document.getElementById("forget").addEventListener("click", forgetProject); await document.fonts.ready; fontEmbedCss = await htmlToImage.getFontEmbedCSS(slide); diff --git a/post1-alt.html b/post1-alt.html index 5d1ca27..7198bf5 100644 --- a/post1-alt.html +++ b/post1-alt.html @@ -1025,20 +1025,64 @@ const PROJECT = "post1-alt"; const STORE = "hivegrid.projekt." + PROJECT; - function saveState() { - try { - localStorage.setItem(STORE, JSON.stringify({ - v: 1, variant, tilt, superSample, continuous, view, tagOut, frameStyle, - current, layoutState, bgState, edits, - })); - markSaved(); - } catch (err) { console.warn("Speichern fehlgeschlagen", err); } + const STATE_URL = "./state/" + PROJECT + ".json"; + + function currentState() { + return { + v: 1, variant, tilt, superSample, continuous, view, tagOut, frameStyle, + current, layoutState, bgState, edits, + }; } - function loadState() { - let d = null; - try { d = JSON.parse(localStorage.getItem(STORE) || "null"); } catch (_) {} + /* Der Stand geht in zwei Ablagen. Sofort in den localStorage, damit die + Rückmeldung nicht auf das Netz wartet, und gebündelt in + state/.json. Nur die Datei lässt sich pushen, also ist sie die + Wahrheit; der localStorage ist der Rückfall, wenn der Server nicht läuft. */ + let pushTimer = null; + function saveState() { + const state = currentState(); + try { localStorage.setItem(STORE, JSON.stringify(state)); } + catch (err) { console.warn("Speichern fehlgeschlagen", err); } + markSaved(); + clearTimeout(pushTimer); + pushTimer = setTimeout(() => pushState(state), 600); + } + + async function pushState(state) { + pushTimer = null; + try { + const res = await fetch("/state?project=" + PROJECT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(state), + }); + if (!res.ok) throw new Error("HTTP " + res.status); + } catch (err) { console.warn("Stand nicht in die Datei geschrieben", err); } + } + + // Ein Reload darf keine Änderung verschlucken, die noch im Timer hängt. + addEventListener("beforeunload", () => { + if (!pushTimer) return; + clearTimeout(pushTimer); + navigator.sendBeacon("/state?project=" + PROJECT, + new Blob([JSON.stringify(currentState())], { type: "application/json" })); + }); + + async function loadState() { + let d = null, fromFile = false; + // Erst die Datei aus dem Repo, dann der Browser. Wer das Projekt frisch + // gezogen hat, soll den gepushten Stand sehen und nicht seinen eigenen alten. + try { + const res = await fetch(STATE_URL, { cache: "no-store" }); + if (res.ok) { d = await res.json(); fromFile = true; } + } catch (_) {} + if (!d) { + try { d = JSON.parse(localStorage.getItem(STORE) || "null"); } catch (_) {} + } if (!d || d.v !== 1) return false; + // Stand aus dem Browser, aber noch keine Datei: einmalig hinueberschreiben, + // sonst bliebe ein ueber Wochen gewachsener Stand ungeteilt liegen. + if (!fromFile) setTimeout(() => pushState(currentState()), 0); // Nur übernehmen, was zur aktuellen Slide-Anzahl passt: Änderungen am // Aufbau dürfen keinen kaputten Stand einspielen. if (Array.isArray(d.layoutState) && d.layoutState.length === SLIDES.length) { @@ -1072,8 +1116,13 @@ } function forgetProject() { + clearTimeout(pushTimer); pushTimer = null; try { localStorage.removeItem(STORE); } catch (_) {} - location.reload(); + // Auch die Datei muss weg, sonst spielt sie den alten Stand beim nächsten + // Laden wieder ein. + fetch("/state?project=" + PROJECT, { method: "DELETE" }) + .catch(() => {}) + .then(() => location.reload()); } // Per-slide so a single slide can carry a different ground. const bgState = SLIDES.map(() => ({ bg: "formen", int: "dezent" })); @@ -2124,7 +2173,7 @@ try { setStatus("Lade Screens ..."); await preloadAssets(); - const restored = loadState(); + const restored = await loadState(); buildDeck(); deck.dataset.view = view; press("variants", "v", variant); diff --git a/post1.html b/post1.html index 3aa7441..c5b44d3 100644 --- a/post1.html +++ b/post1.html @@ -1072,21 +1072,64 @@ halten, nicht der Kopf. */ const PROJECT = "post1"; const STORE = "hivegrid.projekt." + PROJECT; + const STATE_URL = "./state/" + PROJECT + ".json"; - function saveState() { - try { - localStorage.setItem(STORE, JSON.stringify({ - v: 1, variant, tilt, superSample, continuous, view, tagOut, frameStyle, - current, layoutState, bgState, edits, - })); - markSaved(); - } catch (err) { console.warn("Speichern fehlgeschlagen", err); } + function currentState() { + return { + v: 1, variant, tilt, superSample, continuous, view, tagOut, frameStyle, + current, layoutState, bgState, edits, + }; } - function loadState() { - let d = null; - try { d = JSON.parse(localStorage.getItem(STORE) || "null"); } catch (_) {} + /* Der Stand geht in zwei Ablagen. Sofort in den localStorage, damit die + Rückmeldung nicht auf das Netz wartet, und gebündelt in + state/.json. Nur die Datei lässt sich pushen, also ist sie die + Wahrheit; der localStorage ist der Rückfall, wenn der Server nicht läuft. */ + let pushTimer = null; + function saveState() { + const state = currentState(); + try { localStorage.setItem(STORE, JSON.stringify(state)); } + catch (err) { console.warn("Speichern fehlgeschlagen", err); } + markSaved(); + clearTimeout(pushTimer); + pushTimer = setTimeout(() => pushState(state), 600); + } + + async function pushState(state) { + pushTimer = null; + try { + const res = await fetch("/state?project=" + PROJECT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(state), + }); + if (!res.ok) throw new Error("HTTP " + res.status); + } catch (err) { console.warn("Stand nicht in die Datei geschrieben", err); } + } + + // Ein Reload darf keine Änderung verschlucken, die noch im Timer hängt. + addEventListener("beforeunload", () => { + if (!pushTimer) return; + clearTimeout(pushTimer); + navigator.sendBeacon("/state?project=" + PROJECT, + new Blob([JSON.stringify(currentState())], { type: "application/json" })); + }); + + async function loadState() { + let d = null, fromFile = false; + // Erst die Datei aus dem Repo, dann der Browser. Wer das Projekt frisch + // gezogen hat, soll den gepushten Stand sehen und nicht seinen eigenen alten. + try { + const res = await fetch(STATE_URL, { cache: "no-store" }); + if (res.ok) { d = await res.json(); fromFile = true; } + } catch (_) {} + if (!d) { + try { d = JSON.parse(localStorage.getItem(STORE) || "null"); } catch (_) {} + } if (!d || d.v !== 1) return false; + // Stand aus dem Browser, aber noch keine Datei: einmalig hinueberschreiben, + // sonst bliebe ein ueber Wochen gewachsener Stand ungeteilt liegen. + if (!fromFile) setTimeout(() => pushState(currentState()), 0); // Nur übernehmen, was zur aktuellen Slide-Anzahl passt: Änderungen am // Aufbau dürfen keinen kaputten Stand einspielen. if (Array.isArray(d.layoutState) && d.layoutState.length === SLIDES.length) { @@ -1120,8 +1163,13 @@ } function forgetProject() { + clearTimeout(pushTimer); pushTimer = null; try { localStorage.removeItem(STORE); } catch (_) {} - location.reload(); + // Auch die Datei muss weg, sonst spielt sie den alten Stand beim nächsten + // Laden wieder ein. + fetch("/state?project=" + PROJECT, { method: "DELETE" }) + .catch(() => {}) + .then(() => location.reload()); } // Per-slide so a single slide can carry a different ground. const bgState = SLIDES.map(() => ({ bg: "formen", int: "dezent" })); @@ -2115,7 +2163,7 @@ try { setStatus("Lade Screens ..."); await preloadAssets(); - const restored = loadState(); + const restored = await loadState(); buildDeck(); deck.dataset.view = view; press("variants", "v", variant); diff --git a/serve.py b/serve.py index e66256d..353c74b 100755 --- a/serve.py +++ b/serve.py @@ -1,31 +1,101 @@ #!/usr/bin/env python3 -"""Static server for the post generator, plus a save endpoint. +"""Static server for the post generator, plus a save and a state endpoint. The browser download route turned out to be unreliable here: with a data: URL it opened a blank tab, with a blob: URL it opened the image in a tab. Both times the download attribute was ignored. Since this only ever runs locally, the generator now POSTs the rendered bytes to /save and the file is written straight to the Downloads folder. No browser download machinery involved. + +Der Projektstand liegt nicht mehr nur im localStorage des Browsers, sondern +unter state/.json im Repo. Nur so ueberlebt er einen Rechnerwechsel: +gepusht und gezogen hat die andere Maschine denselben Stand. Der localStorage +bleibt als Zwischenspeicher, die Datei ist die Wahrheit. """ import http.server +import json import os import re import socketserver +import urllib.parse from pathlib import Path PORT = 4321 ROOT = Path(__file__).resolve().parent TARGET = Path.home() / "Downloads" +STATE_DIR = ROOT / "state" SAFE_NAME = re.compile(r"^[A-Za-z0-9._-]{1,120}$") +SAFE_PROJECT = re.compile(r"^[a-z0-9][a-z0-9-]{0,39}$") +MAX_STATE = 4 * 1024 * 1024 class Handler(http.server.SimpleHTTPRequestHandler): def __init__(self, *a, **kw): super().__init__(*a, directory=str(ROOT), **kw) + def _project(self): + """Projektname aus der Anfrage, oder None wenn er nicht taugt.""" + q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) + name = (q.get("project") or [""])[0] + return name if SAFE_PROJECT.match(name) else None + + def _state_path(self, name): + # Der Name ist geprueft, aber der Pfad wird trotzdem gegengerechnet: + # ein Schreibziel ausserhalb von state/ darf es nicht geben. + p = (STATE_DIR / f"{name}.json").resolve() + return p if p.parent == STATE_DIR.resolve() else None + + def do_DELETE(self): + if urllib.parse.urlparse(self.path).path != "/state": + self.send_error(404) + return + name = self._project() + if not name: + self._json(400, '{"error":"ungueltiges Projekt"}') + return + p = self._state_path(name) + if p and p.exists(): + p.unlink() + print(f"Stand verworfen: {p.name}") + self._json(200, '{"ok":true}') + + def _put_state(self): + name = self._project() + if not name: + self._json(400, '{"error":"ungueltiges Projekt"}') + return + length = int(self.headers.get("Content-Length", 0)) + if length <= 0 or length > MAX_STATE: + self._json(400, '{"error":"ungueltige Groesse"}') + return + raw = self.rfile.read(length) + try: + data = json.loads(raw) + except ValueError: + self._json(400, '{"error":"kein JSON"}') + return + p = self._state_path(name) + if p is None: + self._json(400, '{"error":"ungueltiger Pfad"}') + return + STATE_DIR.mkdir(parents=True, exist_ok=True) + # Eingerueckt und mit sortierten Schluesseln, damit git einen lesbaren + # Unterschied zeigt statt einer einzigen langen Zeile. Erst in eine + # Nebendatei, dann umbenennen: ein Abbruch mittendrin darf keinen + # halben Stand hinterlassen. + tmp = p.with_suffix(".json.tmp") + tmp.write_text(json.dumps(data, indent=1, sort_keys=True, + ensure_ascii=False) + "\n", encoding="utf-8") + tmp.replace(p) + self._json(200, '{"ok":true}') + def do_POST(self): - if self.path.split("?")[0] != "/save": + path = urllib.parse.urlparse(self.path).path + if path == "/state": + self._put_state() + return + if path != "/save": self.send_error(404) return @@ -72,7 +142,8 @@ class Handler(http.server.SimpleHTTPRequestHandler): super().end_headers() def log_message(self, fmt, *args): - if "POST" in (args[0] if args else ""): + first = args[0] if args else "" + if "POST /save" in first or "DELETE" in first: super().log_message(fmt, *args) diff --git a/state/post1.json b/state/post1.json new file mode 100644 index 0000000..acb487f --- /dev/null +++ b/state/post1.json @@ -0,0 +1,138 @@ +{ + "bgState": [ + { + "bg": "raster", + "int": "dezent" + }, + { + "bg": "raster", + "int": "dezent" + }, + { + "bg": "raster", + "int": "dezent" + }, + { + "bg": "raster", + "int": "dezent" + }, + { + "bg": "raster", + "int": "dezent" + } + ], + "continuous": "aus", + "current": 0, + "edits": [ + { + "laptop": { + "dx": -15, + "dy": -39, + "size": 827 + }, + "logo-full": { + "dx": -40, + "dy": 597 + }, + "szene": { + "dx": -10, + "dy": -82, + "size": 1020 + }, + "wabe-claim": { + "dx": 16, + "dy": -902 + }, + "wabe-logo": { + "dx": 209, + "dy": 833 + }, + "wabe-tagline": { + "dx": 141, + "dy": -3 + } + }, + { + "badge": { + "dx": -32, + "dy": -73 + }, + "body": { + "dx": 0, + "dy": -93 + }, + "headline": { + "dx": 0, + "dy": -84 + }, + "mock": { + "dx": 232, + "dy": -58, + "size": 706 + }, + "mock-2": { + "dx": -465, + "dy": -267, + "size": 360 + } + }, + { + "badge": { + "dx": -6, + "dy": -30 + }, + "headline": { + "dx": 0, + "dy": 7 + }, + "mock": { + "dx": 6, + "dy": -382, + "size": 870 + }, + "mock-2": { + "dx": -117, + "dy": -6, + "size": 354 + }, + "note": { + "dx": 144, + "dy": -129, + "size": 320 + } + }, + { + "headline": {}, + "mock": { + "dx": 28, + "dy": -141, + "size": 931 + }, + "mock-2": { + "dx": -8, + "dy": -76 + } + }, + { + "mock": { + "dx": -9, + "dy": 16, + "size": 584 + } + } + ], + "frameStyle": "stapel", + "layoutState": [ + "szene", + "standard", + "standard", + "standard", + "standard" + ], + "superSample": 3, + "tagOut": "an", + "tilt": "an", + "v": 1, + "variant": "hell", + "view": "einzeln" +}