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.
This commit is contained in:
Florian Wilde
2026-08-21 12:23:15 +02:00
parent 1f4753b753
commit fcc63ff69a
27 changed files with 189 additions and 4 deletions
+8
View File
@@ -7,6 +7,14 @@ Versionsnummer in der Fußzeile.
> Diese Datei wird erzeugt: `npm run changelog`. > Diese Datei wird erzeugt: `npm run changelog`.
> Gepflegt wird der Verlauf in `src/lib/changelog.ts`. > Gepflegt wird der Verlauf in `src/lib/changelog.ts`.
## 1.7.1 — 21.08.2026
Bilder zu den neuen Funktionen in der Bedienungsanleitung.
- Die Bedienungsanleitung zeigt die Funktionen aus 1.7.0 jetzt auch im Bild: Logo im Code, Dublettenwarnung, Filter nach Art, Freigabe je Gruppe, Kurzhilfe und Domain-Bericht.
- Alle übrigen Bilder mit erneuert — sie stammten noch aus 1.2.0.
- Das Skript, das die Bilder erzeugt, lief seit einer Umbenennung ins Leere und tut es wieder.
## 1.7.0 — 21.08.2026 ## 1.7.0 — 21.08.2026
Logo im Code, Freigabe je Gruppe und ein Domain-Check. Logo im Code, Freigabe je Gruppe und ein Domain-Check.
+11
View File
@@ -114,6 +114,8 @@ trennen kann.
### Ein Logo in die Mitte setzen ### Ein Logo in die Mitte setzen
![Ein Logo in der Mitte des Codes](bilder/16-logo-im-code.png)
Ganz unten in der Gestaltung steht **Logo in der Mitte**. Ein Haken genügt, Ganz unten in der Gestaltung steht **Logo in der Mitte**. Ein Haken genügt,
dann fragt die Anwendung, woher das Bild kommen soll: dann fragt die Anwendung, woher das Bild kommen soll:
@@ -158,6 +160,7 @@ Projekte nebeneinander.
liegen. liegen.
- Ein **Filter nach Art** erscheint, sobald dynamische und statische Codes - Ein **Filter nach Art** erscheint, sobald dynamische und statische Codes
nebeneinander liegen: *Alle Arten*, *Nur dynamische*, *Nur statische*. nebeneinander liegen: *Alle Arten*, *Nur dynamische*, *Nur statische*.
![Die Liste, eingeschränkt auf statische Codes](bilder/18-meine-codes-filter.png)
Steht er auf „Nur statische“, fällt die Scan-Spalte weg und die Sortierung Steht er auf „Nur statische“, fällt die Scan-Spalte weg und die Sortierung
nach Scans mit ihr — bei einer Liste aus lauter Strichen sagt sie nichts. nach Scans mit ihr — bei einer Liste aus lauter Strichen sagt sie nichts.
- Das Symbol neben der Kurz-URL kopiert sie in die Zwischenablage. - Das Symbol neben der Kurz-URL kopiert sie in die Zwischenablage.
@@ -173,6 +176,8 @@ Ein statischer Code ist mit dem Download eigentlich fertig — sein Inhalt
steckt in ihm. Der Knopf **In „Meine Codes“ ablegen** legt ihn trotzdem in die steckt in ihm. Der Knopf **In „Meine Codes“ ablegen** legt ihn trotzdem in die
Liste, damit du ihn wiederfindest und erneut herunterladen kannst. Liste, damit du ihn wiederfindest und erneut herunterladen kannst.
![Warnung vor einem doppelt abgelegten Code](bilder/17-dublettenwarnung.png)
Liegt dort schon ein statischer Code mit **identischem Inhalt**, hält das Liegt dort schon ein statischer Code mit **identischem Inhalt**, hält das
Ablegen an und zeigt, welcher das ist und wann er entstand. Der Knopf heißt Ablegen an und zeigt, welcher das ist und wann er entstand. Der Knopf heißt
dann **Trotzdem ablegen** — es ist eine Warnung, keine Sperre. Verglichen wird dann **Trotzdem ablegen** — es ist eine Warnung, keine Sperre. Verglichen wird
@@ -296,6 +301,8 @@ bearbeiten. Nur neue lassen sich nicht mehr anlegen.
**Freigabe je Gruppe.** Über der Liste steht die Auswahl **Freigabe für**: **Freigabe je Gruppe.** Über der Liste steht die Auswahl **Freigabe für**:
![Freigabe der Typen für eine einzelne Gruppe](bilder/19-verwaltung-typen-gruppe.png)
| Auswahl | Wirkung | | Auswahl | Wirkung |
| --- | --- | | --- | --- |
| Alle Gruppen (Grundeinstellung) | gilt für die ganze Installation | | Alle Gruppen (Grundeinstellung) | gilt für die ganze Installation |
@@ -346,9 +353,13 @@ Knopf **Kurzhilfe** über der Liste führt es in sechs Schritten auf — DNS,
Weiterleitung im Proxy samt der drei nötigen Kopfzeilen, Zertifikat, Weiterleitung im Proxy samt der drei nötigen Kopfzeilen, Zertifikat,
Registrieren, Gruppenzuteilung, Nachprüfen. Registrieren, Gruppenzuteilung, Nachprüfen.
![Die Kurzhilfe zum Hinzufügen einer Domain](bilder/20-domain-kurzhilfe.png)
**Prüfen, ob eine Domain wirklich hierher führt.** Bei jedem Eintrag steht ein **Prüfen, ob eine Domain wirklich hierher führt.** Bei jedem Eintrag steht ein
Knopf **Prüfen**. Er geht den Weg der Scanner nach und meldet vier Dinge: Knopf **Prüfen**. Er geht den Weg der Scanner nach und meldet vier Dinge:
![Der Bericht einer geprüften Domain](bilder/21-domain-check.png)
| Schritt | Was er sagt | | Schritt | Was er sagt |
| --- | --- | | --- | --- |
| DNS | ob der Name auflöst, und auf welche Adressen | | DNS | ob der Name auflöst, und auf welche Adressen |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+159 -3
View File
@@ -7,6 +7,9 @@
* Voraussetzungen: * Voraussetzungen:
* - eine **leere** Instanz (die Beispieldaten würden sonst mit fremden * - eine **leere** Instanz (die Beispieldaten würden sonst mit fremden
* Codes im Bild landen), erreichbar unter BASE * 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 * - AUTH_ENABLED=true, damit Kopfzeile und Verwaltung so aussehen wie im
* Regelbetrieb * Regelbetrieb
* - Playwright: npm i -D playwright && npx playwright install chromium * - Playwright: npm i -D playwright && npx playwright install chromium
@@ -18,6 +21,7 @@ import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { chromium } from "playwright"; import { chromium } from "playwright";
import sharp from "sharp";
const BASE = (process.argv[2] ?? "http://localhost:3000").replace(/\/+$/, ""); const BASE = (process.argv[2] ?? "http://localhost:3000").replace(/\/+$/, "");
const ZIEL = join(dirname(fileURLToPath(import.meta.url)), "bilder"); const ZIEL = join(dirname(fileURLToPath(import.meta.url)), "bilder");
@@ -197,16 +201,47 @@ const handy = await browser.newContext({
locale: "de-DE", locale: "de-DE",
}); });
async function schuss(context, pfad, datei, { ganzeSeite = true, vorher } = {}) { /**
* 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(); const page = await context.newPage();
await page.goto(BASE + pfad, { waitUntil: "networkidle" }); await page.goto(BASE + pfad, { waitUntil: "networkidle" });
if (vorher) await vorher(page); if (vorher) await vorher(page);
await page.waitForTimeout(300); await page.waitForTimeout(300);
await page.screenshot({ path: join(ZIEL, datei), fullPage: ganzeSeite }); 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(); await page.close();
console.log(" ✓", datei); 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:"); console.log("Bilder werden erzeugt:");
await schuss(desktop, "/", "01-startseite.png"); await schuss(desktop, "/", "01-startseite.png");
@@ -242,7 +277,7 @@ await schuss(desktop, "/meine-codes", "06-ziel-bearbeiten.png", {
await page.waitForTimeout(400); await page.waitForTimeout(400);
// Bewusst die letzte Zeile: ein Website-Code, dessen Dialog ganz ins Bild passt. // 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.locator("tbody tr").last().getByRole("button", { name: /bearbeiten/ }).click();
await page.getByRole("heading", { name: "Ziel bearbeiten" }).waitFor(); await page.getByRole("heading", { name: "Code bearbeiten" }).waitFor();
}, },
}); });
@@ -258,8 +293,129 @@ await schuss(desktop, "/verwaltung/gruppen", "13-verwaltung-gruppen.png");
await schuss(desktop, "/verwaltung/domains", "14-verwaltung-domains.png"); await schuss(desktop, "/verwaltung/domains", "14-verwaltung-domains.png");
await schuss(desktop, "/konto", "15-konto.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(); 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 */ /* ------------------------------------------------------------ Aufräumen */
for (const eintrag of angelegt) { for (const eintrag of angelegt) {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "qrcode-generator", "name": "qrcode-generator",
"version": "1.7.0", "version": "1.7.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
+10
View File
@@ -23,6 +23,16 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: readonly ChangelogEntry[] = [ export const CHANGELOG: readonly ChangelogEntry[] = [
{
version: "1.7.1",
date: "2026-08-21",
summary: "Bilder zu den neuen Funktionen in der Bedienungsanleitung.",
changes: [
"Die Bedienungsanleitung zeigt die Funktionen aus 1.7.0 jetzt auch im Bild: Logo im Code, Dublettenwarnung, Filter nach Art, Freigabe je Gruppe, Kurzhilfe und Domain-Bericht.",
"Alle übrigen Bilder mit erneuert — sie stammten noch aus 1.2.0.",
"Das Skript, das die Bilder erzeugt, lief seit einer Umbenennung ins Leere und tut es wieder.",
],
},
{ {
version: "1.7.0", version: "1.7.0",
date: "2026-08-21", date: "2026-08-21",