Bedienungsanleitung mit Bildern

docs/anleitung.md fuehrt vom ersten Code bis zur Auswertung von
Rueckmeldungen: statisch gegen dynamisch, Gestaltung, Meine Codes, die fuenf
Typen mit eigener Seite, Verwaltung, Konto und haeufige Fragen.

Die Bilder erzeugt docs/screenshots.mjs. Von Hand aufgenommene Bilder
veralten beim ersten Umbau — so sind sie in einer Minute neu erzeugt, samt
Beispieldaten, die das Skript hinterher wieder entfernt.
This commit is contained in:
Florian Wilde
2026-08-15 00:26:46 +02:00
parent c8d60d8006
commit e4ff55a6c9
21 changed files with 650 additions and 0 deletions
+9
View File
@@ -8,6 +8,15 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
--- ---
## Anleitungen
- **[Bedienungsanleitung mit Bildern](docs/anleitung.md)** — vom ersten Code
bis zur Auswertung von Rückmeldungen
- **[Anmeldung über Authentik (OIDC)](docs/authentik.md)** — Einrichtung,
Rollen und der Weg zurück
---
## Inhalt ## Inhalt
- [Schritt für Schritt zum laufenden Stack](#schritt-für-schritt-zum-laufenden-stack) - [Schritt für Schritt zum laufenden Stack](#schritt-für-schritt-zum-laufenden-stack)
+322
View File
@@ -0,0 +1,322 @@
# Bedienungsanleitung
Diese Anleitung führt durch die tägliche Arbeit mit dem QR-Code-Generator —
vom ersten Code bis zur Auswertung von Rückmeldungen. Wie der Stack
aufgesetzt wird, steht im [README](../README.md); die Anmeldung über
Authentik in [authentik.md](authentik.md).
> Die Bilder zeigen die Oberfläche mit eingeschalteter Anmeldung. Ohne
> Anmeldung fehlen Kopfzeile rechts und der Bereich „Verwaltung“, alles
> Übrige ist gleich.
---
## Inhalt
- [Der schnellste Weg zum Code](#der-schnellste-weg-zum-code)
- [Statisch oder dynamisch — die wichtigste Entscheidung](#statisch-oder-dynamisch--die-wichtigste-entscheidung)
- [Gestaltung](#gestaltung)
- [Meine Codes](#meine-codes)
- [Die Typen mit eigener Seite](#die-typen-mit-eigener-seite)
- [Feedback einsammeln und auswerten](#feedback-einsammeln-und-auswerten)
- [Verwaltung](#verwaltung)
- [Konto](#konto)
- [Häufige Fragen](#häufige-fragen)
---
## Der schnellste Weg zum Code
Die Startseite fragt „Was möchtest du erstellen?“ und zeigt alle
freigegebenen Typen als Kacheln.
![Startseite mit den QR-Typen](bilder/01-startseite.png)
Ein Klick auf **Website**, die Adresse eintippen — die Vorschau erscheint
sofort — und mit **PNG** oder **SVG** herunterladen. Fertig.
![Website-Formular mit Vorschau](bilder/02-website-formular.png)
Links stehen Inhalt, Art des Codes und Gestaltung, rechts die Vorschau mit
den beiden Download-Knöpfen. Die Vorschau aktualisiert sich bei jeder
Änderung.
**PNG oder SVG?**
| | PNG | SVG |
| --- | --- | --- |
| Wofür | Bildschirm, Office, Web | Druck, große Formate, Schilder |
| Größe | fest (256–2048 px) | frei skalierbar ohne Qualitätsverlust |
| Faustregel | wenn du es einfügst | wenn es jemand druckt |
---
## Statisch oder dynamisch — die wichtigste Entscheidung
| | Statisch | Dynamisch |
| --- | --- | --- |
| Im Code steht | die Zieladresse selbst | eine Kurz-URL dieser Anwendung |
| Ziel später änderbar | **nein** | **ja**, jederzeit |
| Scans werden gezählt | nein | ja |
| Braucht diesen Server | nein, nie | ja, dauerhaft |
| Code ist | dichter (mehr Daten) | schlanker, leichter scannbar |
**Nimm statisch,** wenn die Adresse feststeht und nie wieder geändert wird —
und wenn der Code auch dann noch funktionieren soll, wenn es diese Anwendung
nicht mehr gibt.
**Nimm dynamisch,** wenn etwas gedruckt wird. Ein Aufsteller, ein Flyer, ein
Schild am Werkstor: Solange der Code dynamisch ist, kannst du das Ziel
umbiegen, ohne neu zu drucken.
![Umschalten auf einen dynamischen Code](bilder/03-dynamisch.png)
Beim Umschalten auf **Dynamisch** erscheint ein Feld für den Namen und der
Knopf **Dynamischen Code speichern**. Erst beim Speichern entsteht die
Kurz-URL — deshalb ist der Download bis dahin gesperrt.
> **Der Name ist nur für dich.** Er steht nirgends im Code und sieht ihn
> niemand, der scannt. Er hilft dir, den Code später in der Liste
> wiederzufinden — bei vielen Projekten ist er Gold wert.
---
## Gestaltung
Farben, Größe, Fehlerkorrektur und Rand gelten für Vorschau und Download
gleichermaßen.
**Farben.** Fünf Vorschläge stehen bereit, eigene Werte gehen über den
Farbwähler oder als Hex-Wert. Wird der Kontrast zu schwach, warnt die
Anwendung — ein hübscher, aber unlesbarer Code nützt niemandem. Faustregel:
dunkler Vordergrund auf hellem Hintergrund. Umgekehrt kommen viele
Scanner-Apps nicht zurecht.
**Größe.** 256 bis 2048 Pixel, betrifft nur den PNG-Download. Für Druck lieber
SVG nehmen und die Größe im Layoutprogramm bestimmen.
**Fehlerkorrektur.** Wie viel vom Code beschädigt sein darf, ohne dass er
unlesbar wird:
| Stufe | Verträgt | Wofür |
| --- | --- | --- |
| L | ~7 % | saubere Bildschirmanzeige |
| **M** | ~15 % | **Voreinstellung, für fast alles richtig** |
| Q | ~25 % | Aufkleber, die abgerieben werden |
| H | ~30 % | Außenbereich, Logo mitten im Code |
Höhere Stufen machen den Code dichter — er braucht mehr Fläche, um noch
gescannt zu werden.
**Rand.** Die weiße Zone rundherum. Vier Module sind Standard; weniger kann
das Scannen erschweren, weil der Scanner den Code nicht mehr vom Hintergrund
trennen kann.
---
## Meine Codes
Hier stehen alle **dynamischen** Codes. Statische erscheinen nicht — sie
werden nicht gespeichert.
![Meine Codes als Liste](bilder/04-meine-codes-liste.png)
Die **Liste** ist die Voreinstellung: eine Zeile je Code, gedacht für viele
Projekte nebeneinander.
- Das **Suchfeld** durchsucht Name, Ziel, Kurz-URL und Typ.
- Die **Sortierung** kennt neueste, älteste, Name und Scan-Anzahl.
- Ein **Gruppenfilter** erscheint, sobald Codes in mehr als einer Gruppe
liegen.
- Das Symbol neben der Kurz-URL kopiert sie in die Zwischenablage.
Über die beiden Schalter rechts geht es zur **Kartenansicht** mit großer
Vorschau. Die Wahl merkt sich der Browser.
![Meine Codes als Karten](bilder/05-meine-codes-karten.png)
### Ziel ändern
![Dialog zum Ändern des Ziels](bilder/06-ziel-bearbeiten.png)
Das ist der eigentliche Sinn dynamischer Codes: **Die Kurz-URL bleibt, der
gedruckte Code bleibt gültig, nur das Ziel wechselt.** Die Änderung wirkt
sofort — beim nächsten Scan.
### Löschen
Löschen fragt nach und nennt den betroffenen Code. Danach führt der gedruckte
Code ins Leere und zeigt eine freundliche Seite „Diese Seite gibt es nicht“.
Ein gelöschter Code lässt sich nicht wiederherstellen.
---
## Die Typen mit eigener Seite
Fünf Typen leiten nicht auf eine fremde Adresse weiter, sondern auf eine
Seite, die diese Anwendung selbst ausliefert: **Mehrere Links**, **Social
Media**, **PDF**, **Bild** und **Feedback**.
Diese Seiten sind öffentlich und zeigen bewusst nichts von der Anwendung —
keine Navigation, kein „Verwaltung“, kein Hinweis auf den Generator. Wer
einen Aushang scannt, soll nur den Inhalt sehen.
### Mehrere Links
![Landingpage mit mehreren Links](bilder/07-landing-links.png)
Eine Überschrift, ein kurzer Text und bis zu 20 beschriftete Links. Mit den
Pfeilen im Formular lässt sich die Reihenfolge ändern — das oberste wird am
häufigsten geklickt, also gehört das Wichtigste nach oben.
### Social Media
![Landingpage mit Social-Media-Profilen](bilder/08-landing-social.png)
Dasselbe Prinzip, aber mit einem Katalog von Netzwerken. Als Eingabe genügt
das Kürzel: Aus `@firma` wird beim Speichern `https://instagram.com/firma`.
Eine vollständige Adresse bleibt unverändert, damit auch abweichende
Profil-Adressen möglich sind. Jedes Netzwerk kann nur einmal vorkommen.
### PDF und Bild
![Landingpage mit hinterlegtem PDF](bilder/09-landing-pdf.png)
Beim PDF-Typ lädst du ein Dokument hoch; die Seite zeigt Titel, Dateiname,
Größe und zwei Knöpfe: **Dokument öffnen** und **Herunterladen**.
Beim Bild-Typ sind es bis zu zehn Bilder mit Bildunterschriften, untereinander
als Galerie.
Beides lässt sich später austauschen, **ohne den Code neu zu drucken** — genau
wie ein geändertes Ziel. Erlaubt sind PDF sowie PNG, JPEG, WebP, GIF und AVIF
bis zur eingestellten Grenze (Voreinstellung 10 MB, siehe `MAX_UPLOAD_MB`).
> SVG wird bewusst nicht angenommen: Eine SVG-Datei darf Skripte enthalten,
> und sie würde von derselben Adresse ausgeliefert wie die Anwendung selbst.
### Video
Der Video-Typ erkennt YouTube, Vimeo und direkte Videodateien. Auf der Seite
erscheint ein Abspieler — der Rahmen zum Anbieter lädt aber **erst nach einem
Klick**, und davor steht, dass dabei Daten an den Anbieter gehen.
Video ist der einzige dieser Typen, der auch **statisch** funktioniert: Dann
steht die Videoadresse direkt im Code und führt ohne Umweg dorthin.
---
## Feedback einsammeln und auswerten
![Feedback-Formular auf dem Handy](bilder/09b-landing-feedback.png)
Der Feedback-Typ erzeugt ein Formular mit fünf Sternen oder fünf Gesichtern
und wahlweise einem Textfeld. Alle Texte — Überschrift, Frage, Beschriftung
des Textfelds, Dank nach dem Absenden — lassen sich frei formulieren.
Gespeichert werden nur die Note und der Text. **Kein Name, keine IP-Adresse,
keine Gerätekennung.** Wer einen Aushang scannt und eine Note vergibt, bleibt
anonym — das steht auch für die Scannenden sichtbar unter dem Formular.
![Auswertung der Rückmeldungen](bilder/10-feedback-auswertung.png)
Die Auswertung erreichst du in „Meine Codes“ über das Stern-Symbol in der
Zeile. Sie zeigt den Durchschnitt, die Verteilung der Noten und alle
Kommentare mit Datum.
**Ein praktischer Hinweis:** Ein Code pro Ort. Zwei Codes — einer im
Gastraum, einer am Ausgang — sagen mehr als einer für beides, weil du die
Auswertungen getrennt lesen kannst.
---
## Verwaltung
Der Bereich **Verwaltung** erscheint nur für Administratoren der Installation.
![Übersicht der Verwaltung](bilder/11-verwaltung.png)
### QR-Typen
![QR-Typen freigeben oder abschalten](bilder/12-verwaltung-typen.png)
15 Kacheln sind viel, wenn ihr ohnehin nur drei davon braucht. Hier lassen
sich Typen abschalten; sie verschwinden dann aus der Auswahl.
**Bereits erstellte Codes eines abgeschalteten Typs funktionieren
unverändert weiter** — sie leiten weiter, zählen Scans und lassen sich
bearbeiten. Nur neue lassen sich nicht mehr anlegen.
### Gruppen
![Gruppen verwalten](bilder/13-verwaltung-gruppen.png)
Eine Gruppe bündelt Codes und die Menschen, die damit arbeiten. Jeder Code
gehört genau einer Gruppe; wer nicht in der Gruppe ist, sieht ihn nicht.
Die drei Rollen je Gruppe:
| Rolle | Darf |
| --- | --- |
| **Betrachter** | Codes sehen und herunterladen |
| **Bearbeiter** | zusätzlich anlegen, ändern und löschen |
| **Administrator** | zusätzlich Mitglieder und deren Rollen verwalten |
Darüber liegt der **Administrator der Installation**: Nur er legt Gruppen,
Benutzer und Domains überhaupt an.
### Domains
![Domains verwalten](bilder/14-verwaltung-domains.png)
Sollen Kurz-URLs unter verschiedenen Adressen laufen — etwa je Kunde eine —,
werden die Domains hier eingetragen und Gruppen zugeteilt. Eine Domain kann
mehreren Gruppen dienen; die Codes bleiben trotzdem strikt nach Gruppe
getrennt.
Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese
Anwendung leiten — siehe README, Abschnitt „Mehrere Domains“.
---
## Konto
![Der eigene Kontobereich](bilder/15-konto.png)
Hier stehen die eigene Adresse, die Gruppen mit der jeweiligen Rolle, die
Passwortänderung und der Weg hinaus.
Meldet sich das Konto über Authentik an, steht statt der Passwortänderung ein
Hinweis darauf — das Passwort wird dann beim Anbieter geändert.
---
## Häufige Fragen
**Der Code funktioniert auf dem Handy nicht, am Rechner aber schon.**
Dann zeigt die Kurz-URL auf eine Adresse, die das Handy nicht erreicht.
`BASE_URL` in der `.env` muss die Adresse sein, unter der die Anwendung **von
außen** erreichbar ist — nicht `localhost`.
**Ich habe den Code schon gedruckt und brauche ein anderes Ziel.**
Ist er dynamisch: „Meine Codes“ → Ziel bearbeiten. Ist er statisch: Der Code
muss neu erstellt und neu gedruckt werden. Deshalb bei allem Gedruckten
dynamisch nehmen.
**Der Scan-Zähler steht auf 0, obwohl jemand gescannt hat.**
Gezählt wird nur der Aufruf der Kurz-URL. Statische Codes zählen gar nicht,
weil sie diesen Server nie berühren. Bei Typen mit eigener Seite zählt der
Scan, nicht das Neuladen der Seite.
**Ein Kollege sieht meine Codes nicht.**
Er ist nicht in derselben Gruppe. Verwaltung → Gruppen → Mitglied hinzufügen.
**Der QR-Code wird nicht erkannt.**
Meist zu wenig Kontrast, ein zu kleiner Rand oder zu kleiner Druck.
Faustregel für Druck: mindestens 2 × 2 cm, bei Entfernung mehr — als grober
Anhalt ein Zehntel des Leseabstands.
**Kann ich ein Logo in die Mitte setzen?**
Nicht in dieser Anwendung. Wer es im Layoutprogramm macht: SVG nehmen,
Fehlerkorrektur **H** wählen und höchstens ein Fünftel der Fläche verdecken —
danach unbedingt mit mehreren Geräten testen.
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

+270
View File
@@ -0,0 +1,270 @@
/**
* 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
* - 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";
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",
});
async function schuss(context, pfad, datei, { ganzeSeite = true, vorher } = {}) {
const page = await context.newPage();
await page.goto(BASE + pfad, { waitUntil: "networkidle" });
if (vorher) await vorher(page);
await page.waitForTimeout(300);
await page.screenshot({ path: join(ZIEL, datei), fullPage: ganzeSeite });
await page.close();
console.log(" ✓", datei);
}
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: "Ziel 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");
await browser.close();
/* ------------------------------------------------------------ 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}.`);
+48
View File
@@ -29,6 +29,7 @@
"dotenv": "^17.2.4", "dotenv": "^17.2.4",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.0", "eslint-config-next": "16.3.0",
"playwright": "^1.62.1",
"prisma": "^7.9.1", "prisma": "^7.9.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
@@ -5025,6 +5026,21 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -7095,6 +7111,38 @@
"pathe": "^2.0.3" "pathe": "^2.0.3"
} }
}, },
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/pngjs": { "node_modules/pngjs": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+1
View File
@@ -35,6 +35,7 @@
"dotenv": "^17.2.4", "dotenv": "^17.2.4",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.0", "eslint-config-next": "16.3.0",
"playwright": "^1.62.1",
"prisma": "^7.9.1", "prisma": "^7.9.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"