Der Generator ist von einer Vorschau zu einer Werkbank geworden: Slides lassen sich direkt im Canvas verschieben und skalieren, der Stand bleibt erhalten. - Streifenansicht mit allen Slides nebeneinander, Auswahl per Klick - Durchlaufendes Blatt: jede Slide trägt die Inhalte ihrer Nachbarn, damit verschobene Elemente über die Kante hinweg weiterlaufen - Bearbeiten im Canvas: ziehen, Schriftgrößen und Bildbreiten, Reset je Element oder komplett - Projektstand pro Post im Browser gespeichert, Navigation zwischen den Posts - Arbeitsfläche vom Bedienfeld getrennt, eigener Zoom und Panning - Bildrahmen als Chamfer, rund, Stapel oder aus; Schatten schaltbar - Screenshots zeigen den vollen Screen statt eines Ausschnitts - Kategorien in der Sidebar standardmäßig zugeklappt Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
578 lines
24 KiB
HTML
578 lines
24 KiB
HTML
<!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 · Lösung</a><a class="here" href="./index.html">Post 2 · Statement</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 STORE = "hivegrid.projekt.post2";
|
||
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 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 loadState() {
|
||
let d = null;
|
||
try { d = JSON.parse(localStorage.getItem(STORE) || "null"); } catch (_) {}
|
||
if (!d || d.v !== 1) return false;
|
||
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() {
|
||
try { localStorage.removeItem(STORE); } catch (_) {}
|
||
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 = 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>
|