Post 1 Alt und Post 2 entfernt
Beide Projekte werden nicht mehr gebraucht. Weg sind post1-alt.html, index.html und state/post2.json, dazu der lokale state/post1-alt.json. Mitgezogen, damit nichts ins Leere zeigt: die Projektumschaltung im Bedienfeld samt ihrer CSS-Regeln, die Projekttabelle und die Liste der Stand-Dateien in der README, die Startzeile und der Aufbau in rules.md. Achtung: Ohne index.html liefert http://127.0.0.1:4321/ nur noch die Verzeichnisliste. Der Einstieg ist /post1.html. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
713cac113d
commit
21e1d3009f
6 files changed
+6
-3059
No files matched your search
@@ -21,11 +21,10 @@ Downloads landen direkt in `~/Downloads`, ohne Browser-Dialog.
|
||||
| Projekt | Datei | Inhalt |
|
||||
|---|---|---|
|
||||
| Post 1 · Lösung | `post1.html` | Carousel, 5 Slides |
|
||||
| Post 2 · Statement | `index.html` | Einzelbild |
|
||||
|
||||
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.
|
||||
**Das 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
|
||||
|
||||
@@ -34,9 +33,8 @@ Nicht nur im Browser: jede Änderung landet auch in `state/<projekt>.json`.
|
||||
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
|
||||
state/post1.json Post 1 · Lösung
|
||||
state/post1-merk.json Rückfallpunkt
|
||||
```
|
||||
|
||||
Beim Laden gewinnt die Datei, der localStorage ist nur der Rückfall, wenn der
|
||||
|
||||
-626
@@ -1,626 +0,0 @@
|
||||
<!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>
|
||||
-2407
File diff suppressed because it is too large.
Load diff
-10
@@ -33,15 +33,6 @@
|
||||
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;
|
||||
@@ -777,7 +768,6 @@
|
||||
|
||||
<aside class="panel">
|
||||
<h1>Post 1 · Lösung</h1>
|
||||
<div class="projects"><a class="here" href="./post1.html">Post 1</a><a class="" href="./post1-alt.html">Post 1 · Alt</a><a class="" href="./index.html">Post 2</a></div>
|
||||
<p class="sub">Carousel, 5 Slides · 1080 × 1350 px<span class="saved" id="saved"></span><br><button class="forget" id="forget">Projekt zurücksetzen</button></p>
|
||||
|
||||
<div class="group">
|
||||
|
||||
@@ -4,7 +4,7 @@ Arbeitsgrundlage für alles, was in diesem Repo an Social-Grafiken entsteht.
|
||||
**Stand:** 31.07.2026
|
||||
|
||||
Der Generator läuft lokal: `./serve.sh` → <http://127.0.0.1:4321/post1.html>
|
||||
(Post 1, Carousel) bzw. `/index.html` (Post 2, Statement).
|
||||
(Post 1, Carousel).
|
||||
|
||||
---
|
||||
|
||||
@@ -714,7 +714,6 @@ etwas ins Statusfeld schreibt. **Nicht entfernen.**
|
||||
posts/
|
||||
├── serve.py / serve.sh Statischer Server + /save-Endpunkt (Port 4321)
|
||||
├── post1.html Post 1 · Lösung (Carousel, 5 Slides)
|
||||
├── index.html Post 2 · Statement (Einzelbild)
|
||||
├── rules.md diese Datei
|
||||
├── assets/
|
||||
│ ├── logo.svg, logo-white.svg, cta-honeycomb.svg
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"density": "0.75",
|
||||
"frame": "an",
|
||||
"pattern": "an",
|
||||
"v": 1,
|
||||
"variant": "hell"
|
||||
}
|
||||
Reference in new issue
Block a user