Files
posts/index.html
T
dejanandClaude Opus 5 1359c6eba8 Projektstand liegt im Repo statt nur im Browser
Der Stand hing am localStorage und damit an einem Rechner. Wer das Projekt
woanders zog, sah einen anderen Stand.

Jede Aenderung geht jetzt zusaetzlich in state/<projekt>.json, gebuendelt nach
600 ms. Beim Laden gewinnt die Datei, der localStorage ist nur noch der
Rueckfall fuer den Fall, dass der Server nicht laeuft. Gibt es die Datei noch
nicht, wird der vorhandene Browser-Stand einmalig hineingeschrieben. Ein
Reload verschluckt nichts: was im Timer haengt, geht per sendBeacon raus.

Der Server nimmt den Stand unter POST /state entgegen und loescht ihn unter
DELETE /state. Der Projektname wird gegen ein enges Muster geprueft und der
Zielpfad zusaetzlich gegen state/ gegengerechnet. Geschrieben wird erst in
eine Nebendatei und dann umbenannt, eingerueckt und mit sortierten Schluesseln,
damit git einen lesbaren Unterschied zeigt.

state/*.json gehoeren ab jetzt in jeden Commit, sonst sieht die andere
Maschine die Aenderung nicht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:36:49 +02:00

627 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HiveGrid Post-Generator</title>
<link rel="stylesheet" href="./assets/fonts/manrope.css">
<style>
/* ─── HiveGrid design tokens (mirrored from app/globals.css) ─────────────
Deliberately plain hex, no Tailwind/oklch: the export libraries choke on
modern colour functions, so the slide subtree stays on values they can
serialise. */
:root {
--accent-yellow: #F1B729;
--accent-teal: #0D6E6E;
--surface: #FFFFFF;
--bg-primary: #FAFAFA;
--bg-dark: #0F1A2E;
--text-primary: #1A1A1A;
--text-secondary:#555555;
--text-on-dark: #C8D0DC;
--text-white: #FFFFFF;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { overscroll-behavior: none; }
body {
font-family: 'Manrope', system-ui, sans-serif;
background: #17181F; color: #E8EAEE;
height: 100vh; overflow: hidden; display: flex;
}
/* ─── Projekt-Navigation ─────────────────────────────────────────────── */
.projects { display: flex; gap: 6px; margin-bottom: 18px; }
.projects a {
flex: 1; text-align: center; text-decoration: none;
font-size: 12px; font-weight: 700; padding: 9px 8px; border-radius: 8px;
background: #22242C; color: #8A8F9B; border: 1px solid #2E313B;
}
.projects a:hover { background: #2A2D36; color: #C7CCD6; }
.projects a.here { background: var(--accent-yellow); color: #1A1A1A; border-color: var(--accent-yellow); }
.saved {
font-size: 11px; color: #6FCF97; opacity: 0; transition: opacity .2s;
margin-left: 6px;
}
.saved.on { opacity: 1; }
.forget { font-size: 11px; color: #6E7480; background: none; border: 0;
cursor: pointer; padding: 0; font-family: inherit; }
.forget:hover { color: #EB7C6B; }
/* ─── Control panel ──────────────────────────────────────────────────── */
.panel {
width: 306px; flex: none; height: 100vh; overflow-y: auto;
padding: 28px 22px 40px 28px;
border-right: 1px solid #24262F; background: #17181F;
}
.panel h1 { font-size: 19px; font-weight: 800; letter-spacing: -0.01em; margin-bottom: 4px; }
.panel .sub { font-size: 13px; color: #8A8F9B; margin-bottom: 24px; line-height: 1.5; }
.group { margin-bottom: 22px; }
.group > label {
display: block; font-size: 11px; font-weight: 700; letter-spacing: 0.09em;
text-transform: uppercase; color: #6E7480; margin-bottom: 9px;
}
.opts { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.opts button {
font-family: inherit; font-size: 13px; font-weight: 600;
padding: 11px 10px; border-radius: 8px; cursor: pointer;
background: #22242C; color: #C7CCD6; border: 1px solid #2E313B;
transition: background .15s, border-color .15s, color .15s;
display: flex; align-items: center; gap: 8px;
}
.opts button:hover { background: #2A2D36; }
.opts button[aria-pressed="true"] {
background: var(--accent-yellow); color: #1A1A1A; border-color: var(--accent-yellow);
}
.swatch { width: 12px; height: 12px; border-radius: 3px; flex: none; border: 1px solid rgba(0,0,0,.18); }
.row { display: flex; gap: 8px; }
.row button, .btn {
font-family: inherit; font-size: 13.5px; font-weight: 700;
padding: 13px 14px; border-radius: 8px; cursor: pointer; border: none;
background: var(--accent-yellow); color: #1A1A1A; flex: 1; transition: filter .15s;
}
.row button:hover, .btn:hover { filter: brightness(1.07); }
.btn.secondary { background: #22242C; color: #C7CCD6; border: 1px solid #2E313B; }
.btn:disabled { opacity: .45; cursor: not-allowed; filter: none; }
.keys { font-size: 12px; color: #6E7480; line-height: 1.9; }
.keys kbd {
background: #22242C; border: 1px solid #343843; border-bottom-width: 2px;
border-radius: 4px; padding: 1px 6px; font-family: ui-monospace, monospace;
font-size: 11px; color: #C7CCD6;
}
.status { margin-top: 14px; font-size: 12.5px; color: #6E7480; min-height: 18px; }
.status.ok { color: #6FCF97; }
.status.err { color: #EB7C6B; }
/* ─── Stage ──────────────────────────────────────────────────────────── */
/* Arbeitsfläche, klar abgesetzt von der Bedienleiste. */
.stage {
flex: 1; position: relative; height: 100vh; overflow: auto;
overscroll-behavior: contain;
background-color: #101116;
background-image: radial-gradient(circle, #282B35 1.6px, transparent 1.6px);
background-size: 24px 24px;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 14px; padding: 48px;
}
.stage-meta { font-size: 12px; color: #6E7480; letter-spacing: .04em; }
/* The wrapper reserves the *scaled* footprint so the page doesn't jump;
the slide itself always stays at true 1080×1350 so the export is
pixel-exact with no resampling. */
.slide-wrap { width: 486px; height: 607.5px; flex: none; }
.slide-scaler { transform: scale(0.45); transform-origin: top left; }
/* ─── The slide — exactly Instagram 4:5 (1080 × 1350) ────────────────── */
.slide {
width: 1080px; height: 1350px;
position: relative; overflow: hidden;
display: flex; align-items: center; justify-content: center;
font-family: 'Manrope', system-ui, sans-serif;
}
/* Honeycomb — a real seamless tile across the full 1080×1350, generated in
JS from the same G polygon as the mark. Full-bleed on purpose: cropping it
inside a card made it read as a stray texture instead of a ground. */
.pattern { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.slide[data-pattern="aus"] .pattern { display: none; }
/* Chamfered frame as an outline, not a filled plate — a filled card would
cover the pattern, which is exactly what made the last version look cut off. */
.frame { position: absolute; inset: 40px; pointer-events: none; }
.slide[data-frame="aus"] .frame { display: none; }
.content {
position: relative; z-index: 1;
display: flex; flex-direction: column; align-items: center;
text-align: center; padding: 0 104px;
}
/* Eyebrow as a proper label: uppercase, tracked out, single line. Sentence
case at this size wrapped onto two lines and read like body copy. */
.eyebrow {
font-size: 30px; font-weight: 700;
letter-spacing: 0.15em; text-transform: uppercase;
line-height: 1.2; white-space: nowrap;
margin-bottom: 84px;
}
/* Top accent = colour (the marker is reserved for the payoff below). */
.eyebrow .a { color: var(--accent-on); }
.mark { height: 320px; width: auto; display: block; }
.rule { width: 104px; height: 6px; border-radius: 3px; margin: 74px 0 34px; }
.tagline {
font-size: 88px; font-weight: 800;
letter-spacing: -0.028em; line-height: 1.04;
}
/* Bottom accent = marker. */
.tagline .a {
background: linear-gradient(transparent 58%, var(--marker) 58%, var(--marker) 94%, transparent 94%);
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
padding: 0 6px;
}
/* ─── Variant: hell ──────────────────────────────────────────────────── */
.slide[data-variant="hell"] {
background: var(--surface);
--accent-on: #C8901A; --marker: var(--accent-yellow);
--pattern-color: var(--accent-yellow); --pattern-opacity: .085;
--frame-color: #E5E5E5;
}
.slide[data-variant="hell"] .eyebrow { color: var(--text-secondary); }
.slide[data-variant="hell"] .tagline { color: var(--text-primary); }
.slide[data-variant="hell"] .mark { color: var(--accent-yellow); }
.slide[data-variant="hell"] .rule { background: var(--accent-yellow); }
/* ─── Variant: dunkel ────────────────────────────────────────────────── */
.slide[data-variant="dunkel"] {
background: var(--bg-dark);
--accent-on: var(--accent-yellow); --marker: var(--accent-yellow);
--pattern-color: var(--accent-yellow); --pattern-opacity: .10;
--frame-color: rgba(255,255,255,.16);
}
.slide[data-variant="dunkel"] .eyebrow { color: var(--text-on-dark); }
.slide[data-variant="dunkel"] .tagline { color: var(--text-white); }
.slide[data-variant="dunkel"] .mark { color: var(--accent-yellow); }
.slide[data-variant="dunkel"] .rule { background: var(--accent-yellow); }
.slide[data-variant="dunkel"] .tagline .a { color: #1A1A1A; }
/* ─── Variant: gelb ──────────────────────────────────────────────────── */
.slide[data-variant="gelb"] {
background: var(--accent-yellow);
--accent-on: #FFFFFF; --marker: #1A1A1A;
--pattern-color: #1A1A1A; --pattern-opacity: .07;
--frame-color: rgba(26,26,26,.22);
}
.slide[data-variant="gelb"] .eyebrow { color: #6B5210; }
.slide[data-variant="gelb"] .tagline { color: #1A1A1A; }
.slide[data-variant="gelb"] .mark { color: #1A1A1A; }
.slide[data-variant="gelb"] .rule { background: #1A1A1A; }
.slide[data-variant="gelb"] .tagline .a { color: var(--accent-yellow); }
/* ─── Variant: teal ──────────────────────────────────────────────────── */
.slide[data-variant="teal"] {
background: var(--accent-teal);
--accent-on: var(--accent-yellow); --marker: var(--accent-yellow);
--pattern-color: #FFFFFF; --pattern-opacity: .07;
--frame-color: rgba(255,255,255,.18);
}
.slide[data-variant="teal"] .eyebrow { color: #A8D4D4; }
.slide[data-variant="teal"] .tagline { color: var(--text-white); }
.slide[data-variant="teal"] .mark { color: var(--accent-yellow); }
.slide[data-variant="teal"] .rule { background: var(--accent-yellow); }
.slide[data-variant="teal"] .tagline .a { color: var(--accent-teal); }
</style>
</head>
<body>
<aside class="panel">
<h1>Post-Generator</h1>
<div class="projects"><a class="" href="./post1.html">Post 1</a><a class="" href="./post1-alt.html">Post 1 · Alt</a><a class="here" href="./index.html">Post 2</a></div>
<p class="sub">Einzelbild · 1080 × 1350 px<span class="saved" id="saved"></span><br><button class="forget" id="forget">Projekt zurücksetzen</button></p>
<div class="group">
<label>Variante</label>
<div class="opts" id="variants">
<button data-v="hell" aria-pressed="true"><span class="swatch" style="background:#FFFFFF"></span>Hell</button>
<button data-v="dunkel" aria-pressed="false"><span class="swatch" style="background:#0F1A2E"></span>Dunkel</button>
<button data-v="gelb" aria-pressed="false"><span class="swatch" style="background:#F1B729"></span>Gelb</button>
<button data-v="teal" aria-pressed="false"><span class="swatch" style="background:#0D6E6E"></span>Teal</button>
</div>
</div>
<div class="group">
<label>Wabenraster</label>
<div class="opts" id="patterns">
<button data-p="an" aria-pressed="true">An</button>
<button data-p="aus" aria-pressed="false">Aus</button>
</div>
</div>
<div class="group">
<label>Dichte</label>
<div class="opts" id="densities">
<button data-d="0.40" aria-pressed="false">Fein</button>
<button data-d="0.55" aria-pressed="true">Mittel</button>
<button data-d="0.75" aria-pressed="false">Grob</button>
<button data-d="1.00" aria-pressed="false">XL</button>
</div>
</div>
<div class="group">
<label>Rahmen</label>
<div class="opts" id="frames">
<button data-f="an" aria-pressed="true">An</button>
<button data-f="aus" aria-pressed="false">Aus</button>
</div>
</div>
<div class="group">
<label>Download</label>
<div class="row" style="margin-bottom:8px">
<button id="dl-png" disabled>PNG</button>
<button id="dl-jpg" class="btn secondary" disabled>JPG</button>
</div>
<button id="dl-all" class="btn secondary" style="width:100%" disabled>Alle 4 Varianten als ZIP</button>
</div>
<div class="group" style="margin-bottom:0">
<label>Tastatur</label>
<div class="keys">
<kbd>1</kbd>–<kbd>4</kbd> Variante · <kbd>P</kbd> Raster · <kbd>R</kbd> Rahmen<br>
<kbd>D</kbd> PNG laden · <kbd>A</kbd> alle als ZIP
</div>
</div>
<div class="status" id="status">Lade Schriften und Assets ...</div>
</aside>
<main class="stage">
<div class="slide-wrap">
<div class="slide-scaler">
<!-- ─── SLIDE ─────────────────────────────────────────────────────── -->
<div class="slide" id="slide" data-variant="hell" data-pattern="an" data-frame="an">
<svg class="pattern" id="pattern" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"></svg>
<svg class="frame" viewBox="0 0 1000 1270" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<polygon points="34,0 966,0 1000,34 1000,1236 966,1270 34,1270 0,1236 0,34"
stroke="var(--frame-color)" stroke-width="3"/>
</svg>
<div class="content">
<p class="eyebrow">Vom <span class="a">Handwerker</span> für den <span class="a">Handwerker</span></p>
<!-- G mark only, lifted straight out of logo.svg (the single yellow
polygon), normalised to a 0,0-based 384.5 × 332.9 box. Inline
rather than <img> so each variant recolours it via currentColor. -->
<svg class="mark" viewBox="0 0 384.5 332.9" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="HiveGrid">
<polygon fill="currentColor" points="384.5,166.5 336.4,83.2 192.2,166.5 288.3,166.5 240.3,249.7 144.2,249.7 96.1,166.5 144.2,83.2 336.4,83.2 298,16.8 288.3,0 96.1,0 0,166.5 96.1,332.9 288.3,332.9"/>
</svg>
<div class="rule"></div>
<p class="tagline"><span class="a">Verbinden</span><br>statt ersetzen</p>
</div>
</div>
<!-- ─── /SLIDE ────────────────────────────────────────────────────── -->
</div>
</div>
<p class="stage-meta">Vorschau 45 %, Export in voller Auflösung</p>
</main>
<script src="./vendor/html-to-image.js"></script>
<script src="./vendor/jszip.min.js"></script>
<script>
(function () {
"use strict";
/* A syntax or temporal-dead-zone error at this level kills the whole script
silently: the panel renders from static markup, but nothing is ever built
and no status is set. Surface it instead of showing an empty stage. */
window.addEventListener("error", e => {
const el = document.getElementById("status");
if (el) {
el.textContent = "Skriptfehler: " + (e.message || e.error);
el.className = "status err";
}
});
const slide = document.getElementById("slide");
const statusEl = document.getElementById("status");
const patternEl = document.getElementById("pattern");
const VARIANTS = ["hell", "dunkel", "gelb", "teal"];
const SVG_NS = "http://www.w3.org/2000/svg";
// The G mark, normalised to a 0,0-based box.
const G_POINTS = "384.5,166.5 336.4,83.2 192.2,166.5 288.3,166.5 240.3,249.7 144.2,249.7 96.1,166.5 144.2,83.2 336.4,83.2 298,16.8 288.3,0 96.1,0 0,166.5 96.1,332.9 288.3,332.9";
const G_W = 384.5, G_H = 332.9;
let fontEmbedCss = "";
/* ─── Projektstand ────────────────────────────────────────────────────────
Gleiches Prinzip wie bei Post 1: Das Werkzeug hält den Stand. */
const PROJECT = "post2";
const STORE = "hivegrid.projekt." + PROJECT;
const STATE_URL = "./state/" + PROJECT + ".json";
let savedTimer = null;
function markSaved() {
const el = document.getElementById("saved");
if (!el) return;
el.textContent = "gespeichert";
el.classList.add("on");
clearTimeout(savedTimer);
savedTimer = setTimeout(() => el.classList.remove("on"), 1200);
}
function currentState() {
return {
v: 1,
variant: slide.dataset.variant,
pattern: slide.dataset.pattern,
frame: slide.dataset.frame,
density: currentDensity,
};
}
/* Der Stand geht in zwei Ablagen. Sofort in den localStorage, damit die
Rückmeldung nicht auf das Netz wartet, und gebündelt in
state/<projekt>.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);
if (d.density) setDensity(d.density);
return true;
}
function forgetProject() {
clearTimeout(pushTimer); pushTimer = null;
try { localStorage.removeItem(STORE); } catch (_) {}
// 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) {
statusEl.textContent = msg;
statusEl.className = "status" + (kind ? " " + kind : "");
}
/* Builds a seamless flat-top honeycomb tile.
For flat-top hexagons the columns step by 3/4 of the width and neighbouring
columns are offset vertically by half a row, so one tile = two columns wide
and one row high. The copies hanging outside the tile are what makes the
repeat seamless — without them you get the visible seams of the last try. */
function buildPattern(scale) {
const colStep = G_W * 0.75; // 288.4
const rowStep = G_H; // 332.9
const tileW = colStep * 2; // 576.8
const tileH = rowStep;
// Hex centres that touch this tile, including the ones bleeding over the edges.
const centres = [
[0, 0], [tileW, 0], [0, tileH], [tileW, tileH],
[colStep, rowStep / 2], [colStep, -rowStep / 2], [colStep, tileH + rowStep / 2],
];
while (patternEl.firstChild) patternEl.removeChild(patternEl.firstChild);
const defs = document.createElementNS(SVG_NS, "defs");
const pat = document.createElementNS(SVG_NS, "pattern");
pat.setAttribute("id", "hexpat");
pat.setAttribute("patternUnits", "userSpaceOnUse");
pat.setAttribute("width", tileW);
pat.setAttribute("height", tileH);
pat.setAttribute("patternTransform", "scale(" + scale + ")");
for (const [cx, cy] of centres) {
const p = document.createElementNS(SVG_NS, "polygon");
p.setAttribute("points", G_POINTS);
p.setAttribute("fill", "currentColor");
p.setAttribute("transform", "translate(" + (cx - G_W / 2) + "," + (cy - G_H / 2) + ")");
pat.appendChild(p);
}
defs.appendChild(pat);
patternEl.appendChild(defs);
const rect = document.createElementNS(SVG_NS, "rect");
rect.setAttribute("width", "100%");
rect.setAttribute("height", "100%");
rect.setAttribute("fill", "url(#hexpat)");
patternEl.appendChild(rect);
// Colour + opacity come from the active variant.
patternEl.style.color = "var(--pattern-color)";
patternEl.style.opacity = "var(--pattern-opacity)";
}
function press(containerId, attr, value) {
document.querySelectorAll("#" + containerId + " button").forEach(b =>
b.setAttribute("aria-pressed", String(b.dataset[attr] === value))
);
}
function setVariant(v) { slide.dataset.variant = v; press("variants", "v", v); }
function setPattern(p) { slide.dataset.pattern = p; press("patterns", "p", p); }
function setFrame(f) { slide.dataset.frame = f; press("frames", "f", f); }
let currentDensity = "0.55";
function setDensity(d) { currentDensity = d; buildPattern(Number(d)); press("densities", "d", d); }
/* Renders at pixelRatio 1 against a node that is already 1080×1350, so the
output is exactly Instagram's spec with no upscaling artefacts. */
function renderOptions() {
return {
width: 1080, height: 1350, pixelRatio: 1,
fontEmbedCSS: fontEmbedCss,
// The scaler transform is a preview-only concern — neutralise it for the
// export, otherwise the bitmap comes out at 45 %.
style: { transform: "none", transformOrigin: "top left" },
};
}
/* A 1080x1350 PNG as a data: URL runs to several megabytes. Browsers routinely
ignore the download attribute on those and navigate to the URL instead,
which shows up as a blank tab. Blob URLs are handled properly. */
/* Local-only save endpoint, see serve.py. */
async function downloadBlob(blob, filename) {
const res = await fetch("/save", {
method: "POST",
headers: { "X-Filename": filename },
body: blob,
});
if (!res.ok) throw new Error("Speichern fehlgeschlagen: HTTP " + res.status);
return (await res.json()).path;
}
const nameFor = ext => "hivegrid-post2-statement-" + slide.dataset.variant + "." + ext;
async function downloadPng() {
setStatus("Rendere PNG ...");
const blob = await htmlToImage.toBlob(slide, renderOptions());
if (!blob) throw new Error("Rendern lieferte kein Bild");
downloadBlob(blob, nameFor("png"));
setStatus("PNG gespeichert. 1080 × 1350", "ok");
}
async function downloadJpg() {
setStatus("Rendere JPG ...");
const opts = Object.assign(renderOptions(), { quality: 0.9 });
const url = await htmlToImage.toJpeg(slide, opts);
const blob = await (await fetch(url)).blob();
downloadBlob(blob, nameFor("jpg"));
setStatus("JPG gespeichert. Qualität 90 %", "ok");
}
async function downloadAll() {
const zip = new JSZip();
const keep = slide.dataset.variant;
for (const v of VARIANTS) {
setStatus("Rendere " + v + " ...");
setVariant(v);
await document.fonts.ready;
zip.file("hivegrid-post2-statement-" + v + ".png",
await htmlToImage.toBlob(slide, renderOptions()));
}
setVariant(keep);
setStatus("Packe ZIP ...");
downloadBlob(await zip.generateAsync({ type: "blob" }), "hivegrid-post2-statement.zip");
setStatus("4 Varianten als ZIP gespeichert", "ok");
}
function guard(fn) {
return () => fn().catch(err => {
console.error(err);
setStatus("Fehler: " + err.message, "err");
});
}
document.getElementById("variants").addEventListener("click", e => {
const b = e.target.closest("button"); if (b) { setVariant(b.dataset.v); saveState(); }
});
document.getElementById("patterns").addEventListener("click", e => {
const b = e.target.closest("button"); if (b) { setPattern(b.dataset.p); saveState(); }
});
document.getElementById("frames").addEventListener("click", e => {
const b = e.target.closest("button"); if (b) { setFrame(b.dataset.f); saveState(); }
});
document.getElementById("densities").addEventListener("click", e => {
const b = e.target.closest("button"); if (b) { setDensity(b.dataset.d); saveState(); }
});
document.getElementById("dl-png").addEventListener("click", guard(downloadPng));
document.getElementById("dl-jpg").addEventListener("click", guard(downloadJpg));
document.getElementById("dl-all").addEventListener("click", guard(downloadAll));
document.addEventListener("keydown", e => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
const k = e.key.toLowerCase();
if (k >= "1" && k <= "4") setVariant(VARIANTS[Number(k) - 1]);
else if (k === "p") setPattern(slide.dataset.pattern === "an" ? "aus" : "an");
else if (k === "r") setFrame(slide.dataset.frame === "an" ? "aus" : "an");
else if (k === "d") guard(downloadPng)();
else if (k === "a") guard(downloadAll)();
});
/* Boot: build the tile, wait for Manrope to actually be resident (otherwise
the first export silently falls back to a system font), and pre-compute the
font CSS once instead of on every render. */
(async function boot() {
try {
buildPattern(0.55);
const restored = await loadState();
document.getElementById("forget").addEventListener("click", forgetProject);
await document.fonts.ready;
fontEmbedCss = await htmlToImage.getFontEmbedCSS(slide);
["dl-png", "dl-jpg", "dl-all"].forEach(id => document.getElementById(id).disabled = false);
setStatus(restored ? "Projektstand geladen" : "Bereit. 1080 × 1350 px", "ok");
} catch (err) {
console.error(err);
setStatus("Fehler beim Laden: " + err.message, "err");
}
})();
})();
</script>
</body>
</html>