Files
Florian Wilde fcc63ff69a Version 1.7.1: Bilder zu den neuen Funktionen
Die Bedienungsanleitung hatte zu jedem alten Abschnitt ein Bild und zu
keinem der sechs neuen. Sechs Aufnahmen kommen dazu: Logo im Code,
Dublettenwarnung, Filter nach Art, Freigabe je Gruppe, Kurzhilfe und der
Bericht einer geprueften Domain.

Das Skript dafuer lief seit einer Umbenennung ins Leere — es wartete auf
einen Dialogtitel "Ziel bearbeiten", der inzwischen "Code bearbeiten"
heisst. Damit sind auch die uebrigen sechzehn Bilder neu; sie stammten
noch aus 1.2.0 und zeigten eine aeltere Oberflaeche.

schuss() kann jetzt einen Ausschnitt aufnehmen statt der ganzen Seite —
fuer Dialoge und einzelne Karten, bei denen die volle Seite mehr verdeckt
als zeigt. Das Beispiellogo entsteht im Skript, damit kein ungepflegtes
Bild im Repo liegt.

In der Kopfdoku steht jetzt, dass BASE_URL der Instanz auf dieselbe
Adresse zeigen muss wie BASE — sonst erreicht die Anwendung sich beim
Domain-Check nicht selbst und das Bild zeigt einen Fehlschlag.
2026-08-21 12:23:15 +02:00

427 lines
14 KiB
JavaScript

/**
* Erzeugt die Bildschirmfotos für docs/anleitung.md.
*
* Warum als Skript und nicht von Hand: Bilder in einer Anleitung veralten
* beim ersten Umbau. So lassen sie sich in einer Minute neu erzeugen.
*
* Voraussetzungen:
* - eine **leere** Instanz (die Beispieldaten würden sonst mit fremden
* Codes im Bild landen), erreichbar unter BASE
* - `BASE_URL` der Instanz muss auf **dieselbe** Adresse zeigen wie BASE.
* Sonst scheitert der Domain-Check im Bild 21, weil die Anwendung sich
* selbst unter der eingetragenen Adresse nicht erreicht.
* - AUTH_ENABLED=true, damit Kopfzeile und Verwaltung so aussehen wie im
* Regelbetrieb
* - Playwright: npm i -D playwright && npx playwright install chromium
*
* Aufruf:
* node docs/screenshots.mjs http://localhost:3000
*/
import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { chromium } from "playwright";
import sharp from "sharp";
const BASE = (process.argv[2] ?? "http://localhost:3000").replace(/\/+$/, "");
const ZIEL = join(dirname(fileURLToPath(import.meta.url)), "bilder");
await mkdir(ZIEL, { recursive: true });
const ADMIN = { email: "demo@example.com", password: "nur-fuer-die-bilder-1" };
const DESIGN = { fgColor: "#312E81", bgColor: "#FFFFFF", size: 512, ecLevel: "M", margin: 4 };
/* ------------------------------------------------------------- Anmelden */
let cookie = null;
async function api(pfad, init = {}) {
const headers = new Headers(init.headers ?? {});
if (init.json !== undefined) headers.set("Content-Type", "application/json");
if (cookie) headers.set("Cookie", cookie);
const response = await fetch(BASE + pfad, {
...init,
headers,
body: init.json !== undefined ? JSON.stringify(init.json) : init.body,
redirect: "manual",
});
const gesetzt = response.headers.get("set-cookie");
if (gesetzt) cookie = gesetzt.split(";")[0];
let body = null;
try {
body = await response.json();
} catch {
/* leerer Rumpf */
}
return { status: response.status, body };
}
const zustand = await api("/api/auth");
if (zustand.body?.authEnabled) {
const aktion = zustand.body.setupOpen ? "setup" : "login";
const angemeldet = await api("/api/auth", {
method: "POST",
json: { action: aktion, ...ADMIN, name: "Demo Betreiber" },
});
if (angemeldet.status >= 400) {
console.error("Anmeldung fehlgeschlagen:", angemeldet.body);
process.exit(1);
}
}
/* --------------------------------------------------------- Beispieldaten */
const angelegt = [];
async function code(type, data, label) {
const erstellt = await api("/api/codes", {
method: "POST",
json: { type, data, design: DESIGN, label },
});
if (erstellt.status !== 201) {
console.error(`Beispielcode „${label}“ ging nicht:`, erstellt.body);
process.exit(1);
}
angelegt.push(erstellt.body.code);
return erstellt.body.code;
}
await code("website", { url: "https://example.com/nordwind" }, "Projekt Nordwind");
await code("website", { url: "https://example.com/messe" }, "Messestand Halle 3");
const links = await code(
"links",
{
title: "Projekt Nordwind",
description: "Alle Unterlagen auf einen Blick.",
links: [
{ label: "Preisliste als PDF", url: "https://example.com/preise.pdf" },
{ label: "Anfahrt zum Werk", url: "https://example.com/anfahrt" },
{ label: "Ansprechpartner", url: "https://example.com/kontakt" },
],
},
"Nordwind Linkseite",
);
const social = await code(
"social",
{
title: "Bäckerei Sonnenschein",
description: "Folge uns für Neues aus der Backstube.",
profiles: [
{ network: "instagram", url: "@sonnenschein" },
{ network: "facebook", url: "sonnenschein.backt" },
{ network: "youtube", url: "@sonnenschein" },
{ network: "website", url: "https://sonnenschein.example.com" },
],
},
"Sonnenschein Profile",
);
/* --- Eine Datei zum Vorzeigen: klein, aber gültig ------------------------ */
async function hochladen(kind, name, inhalt, typ) {
const form = new FormData();
form.set("kind", kind);
form.set("file", new File([inhalt], name, { type: typ }));
const antwort = await api("/api/uploads", { method: "POST", body: form });
if (antwort.status !== 201) {
console.error(`Upload „${name}“ ging nicht:`, antwort.body);
process.exit(1);
}
return antwort.body.upload;
}
const pdfDatei = await hochladen(
"pdf",
"preisliste-2026.pdf",
Buffer.from(
"%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n" +
"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n" +
"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]>>endobj\n" +
"trailer<</Root 1 0 R>>\n%%EOF\n",
"latin1",
),
"application/pdf",
);
const pdf = await code(
"pdf",
{
fileId: pdfDatei.id,
filename: pdfDatei.filename,
size: pdfDatei.size,
title: "Preisliste 2026",
description: "Gültig ab Januar, alle Preise ohne Mehrwertsteuer.",
},
"Preisliste",
);
const feedback = await code(
"feedback",
{
title: "Wie war dein Besuch?",
question: "Wie zufrieden bist du?",
scale: "stars",
allowComment: true,
commentLabel: "Möchtest du uns noch etwas sagen?",
thankYou: "Danke, das hilft uns weiter!",
},
"Gastraum",
);
// Ein paar Rückmeldungen, damit die Auswertung nicht leer ist.
for (const [note, text] of [
[5, "Sehr freundlich, gerne wieder."],
[4, ""],
[5, "Der Kuchen war hervorragend."],
[3, "Etwas lange Wartezeit."],
]) {
await api("/api/feedback", {
method: "POST",
json: { codeId: feedback.id, rating: note, comment: text },
});
}
/* ------------------------------------------------------------ Aufnehmen */
const browser = await chromium.launch();
const desktop = await browser.newContext({
viewport: { width: 1200, height: 820 },
deviceScaleFactor: 1,
locale: "de-DE",
});
if (cookie) {
const [name, ...rest] = cookie.split("=");
await desktop.addCookies([{ name, value: rest.join("="), url: BASE }]);
}
const handy = await browser.newContext({
viewport: { width: 390, height: 800 },
deviceScaleFactor: 1,
locale: "de-DE",
});
/**
* Nimmt eine Seite auf.
*
* `bereich` grenzt auf einen Ausschnitt ein — für Dialoge und einzelne Karten,
* bei denen die ganze Seite mehr verdeckt als zeigt.
*/
async function schuss(
context,
pfad,
datei,
{ ganzeSeite = true, vorher, bereich } = {},
) {
const page = await context.newPage();
await page.goto(BASE + pfad, { waitUntil: "networkidle" });
if (vorher) await vorher(page);
await page.waitForTimeout(300);
const ziel = join(ZIEL, datei);
if (bereich) {
await page.locator(bereich).first().screenshot({ path: ziel });
} else {
await page.screenshot({ path: ziel, fullPage: ganzeSeite });
}
await page.close();
console.log(" ✓", datei);
}
/**
* Ein Beispiellogo für die Aufnahmen zum Logo im Code.
*
* Erzeugt statt mitgeliefert: So liegt kein Bild im Repo, das niemand pflegt,
* und die Farbe passt zu den übrigen Aufnahmen.
*/
async function beispiellogo() {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256">
<rect width="256" height="256" rx="56" fill="#312E81"/>
<circle cx="128" cy="128" r="66" fill="none" stroke="#FFFFFF" stroke-width="18"/>
<circle cx="128" cy="128" r="20" fill="#FFFFFF"/>
</svg>`;
return sharp(Buffer.from(svg)).png().toBuffer();
}
console.log("Bilder werden erzeugt:");
await schuss(desktop, "/", "01-startseite.png");
await schuss(desktop, "/erstellen/website", "02-website-formular.png", {
vorher: async (page) => {
await page.locator("#website-url").fill("https://example.com/nordwind");
await page.waitForTimeout(900);
},
});
await schuss(desktop, "/erstellen/website", "03-dynamisch.png", {
vorher: async (page) => {
await page.locator("#website-url").fill("https://example.com/nordwind");
await page.locator('button[data-mode="dynamic"]').click();
await page.waitForTimeout(900);
},
});
await schuss(desktop, "/meine-codes", "04-meine-codes-liste.png");
await schuss(desktop, "/meine-codes", "05-meine-codes-karten.png", {
vorher: async (page) => {
await page.locator('button[data-view="cards"]').click();
await page.waitForTimeout(600);
},
});
await schuss(desktop, "/meine-codes", "06-ziel-bearbeiten.png", {
ganzeSeite: false,
vorher: async (page) => {
await page.locator('button[data-view="list"]').click();
await page.waitForTimeout(400);
// Bewusst die letzte Zeile: ein Website-Code, dessen Dialog ganz ins Bild passt.
await page.locator("tbody tr").last().getByRole("button", { name: /bearbeiten/ }).click();
await page.getByRole("heading", { name: "Code bearbeiten" }).waitFor();
},
});
await schuss(handy, `/s/${links.slug}`, "07-landing-links.png");
await schuss(handy, `/s/${social.slug}`, "08-landing-social.png");
await schuss(handy, `/s/${pdf.slug}`, "09-landing-pdf.png");
await schuss(handy, `/s/${feedback.slug}`, "09b-landing-feedback.png");
await schuss(desktop, `/meine-codes/${feedback.id}/feedback`, "10-feedback-auswertung.png");
await schuss(desktop, "/verwaltung", "11-verwaltung.png");
await schuss(desktop, "/verwaltung/typen", "12-verwaltung-typen.png");
await schuss(desktop, "/verwaltung/gruppen", "13-verwaltung-gruppen.png");
await schuss(desktop, "/verwaltung/domains", "14-verwaltung-domains.png");
await schuss(desktop, "/konto", "15-konto.png");
/* ------------------------------------------- Seit 1.7.0: die neuen Funktionen */
const logo = await beispiellogo();
// Logo in der Mitte: ganze Seite, damit Auswahl und Vorschau zusammen im Bild
// sind — das eine erklärt das andere.
await schuss(desktop, "/erstellen/website", "16-logo-im-code.png", {
vorher: async (page) => {
await page.locator("#website-url").fill("https://example.com/nordwind");
await page.getByRole("checkbox", { name: /Logo in die Mitte/ }).check();
await page.setInputFiles('input[type="file"]', {
name: "logo.png",
mimeType: "image/png",
buffer: logo,
});
await page.waitForTimeout(2500);
},
});
// Für die Dublettenwarnung braucht es einen bereits abgelegten statischen Code.
const statisch = await api("/api/codes", {
method: "POST",
json: {
type: "website",
data: { url: "https://example.com/aushang" },
design: DESIGN,
mode: "static",
label: "Aushang Eingang",
},
});
if (statisch.status === 201) angelegt.push(statisch.body.code);
await schuss(desktop, "/erstellen/website", "17-dublettenwarnung.png", {
bereich: "section:has-text('Art des Codes')",
vorher: async (page) => {
await page.locator("#website-url").fill("https://example.com/aushang");
await page.locator('button[data-mode="static"]').click();
await page.waitForTimeout(900);
await page.getByRole("button", { name: /ablegen/i }).click();
await page.getByText("Diesen Inhalt hast du schon abgelegt").waitFor({
timeout: 15000,
});
},
});
// Filter nach Art — sichtbar, weil jetzt beide Arten in der Liste stehen.
await schuss(desktop, "/meine-codes", "18-meine-codes-filter.png", {
vorher: async (page) => {
await page.getByLabel("Nach Art filtern").selectOption("static");
await page.waitForTimeout(600);
},
});
// Freigabe je Gruppe: eine zweite Gruppe, damit die Auswahl etwas zu wählen hat.
const gruppe = await api("/api/admin/groups", {
method: "POST",
json: { name: "Marketing" },
});
const gruppeId = gruppe.body?.group?.id ?? null;
if (gruppeId) {
// Ein Typ in dieser Gruppe abgeschaltet, damit das Bild den Fall zeigt,
// um den es geht.
await api("/api/admin/types", {
method: "PATCH",
json: { type: "payment", enabled: false, groupId: gruppeId },
});
}
await schuss(desktop, "/verwaltung/typen", "19-verwaltung-typen-gruppe.png", {
vorher: async (page) => {
if (gruppeId) {
await page.getByLabel("Freigabe für").selectOption(gruppeId);
await page.waitForTimeout(900);
}
},
});
// Kurzhilfe: nur der Dialog, die Seite dahinter trägt nichts bei.
await schuss(desktop, "/verwaltung/domains", "20-domain-kurzhilfe.png", {
bereich: "dialog[open]",
vorher: async (page) => {
await page.getByRole("button", { name: "Kurzhilfe" }).click();
await page.locator("dialog[open]").waitFor();
await page.waitForTimeout(400);
},
});
// Domain-Check: der Bericht zur eigenen Adresse. Weil BASE_URL und der Port
// des Containers hier zusammenpassen, geht die Prüfung durch — genau der
// Fall, den die Anleitung zeigen soll.
await schuss(desktop, "/verwaltung/domains", "21-domain-check.png", {
vorher: async (page) => {
await page.getByRole("button", { name: "Prüfen" }).first().click();
await page
.locator("text=/Die Domain führt hierher|So kommt niemand hier an/")
.first()
.waitFor({ timeout: 30000 });
await page.waitForTimeout(400);
},
});
await browser.close();
/* --- Was über die Oberfläche entstand, kennt `angelegt` noch nicht --------- */
const bestand = await api("/api/codes");
for (const eintrag of bestand.body?.codes ?? []) {
if (!angelegt.some((vorhanden) => vorhanden.id === eintrag.id)) {
angelegt.push(eintrag);
}
}
// Die Abweichung und die Gruppe wieder zurücknehmen — das Skript soll eine
// Instanz so hinterlassen, wie es sie vorgefunden hat.
if (gruppeId) {
await api("/api/admin/types", {
method: "PATCH",
json: { type: "payment", enabled: true, groupId: gruppeId },
});
await api(`/api/admin/groups/${gruppeId}`, { method: "DELETE" });
}
/* ------------------------------------------------------------ Aufräumen */
for (const eintrag of angelegt) {
await api(`/api/codes/${eintrag.id}`, { method: "DELETE" });
}
console.log(`\nFertig — ${angelegt.length} Beispielcodes wieder entfernt.`);
console.log(`Die Bilder liegen in ${ZIEL}.`);