diff --git a/README.md b/README.md index 9ba5bfe..d0a85fd 100644 --- a/README.md +++ b/README.md @@ -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 - [Schritt für Schritt zum laufenden Stack](#schritt-für-schritt-zum-laufenden-stack) diff --git a/docs/anleitung.md b/docs/anleitung.md new file mode 100644 index 0000000..7354a98 --- /dev/null +++ b/docs/anleitung.md @@ -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. diff --git a/docs/bilder/01-startseite.png b/docs/bilder/01-startseite.png new file mode 100644 index 0000000..161c39a Binary files /dev/null and b/docs/bilder/01-startseite.png differ diff --git a/docs/bilder/02-website-formular.png b/docs/bilder/02-website-formular.png new file mode 100644 index 0000000..2967911 Binary files /dev/null and b/docs/bilder/02-website-formular.png differ diff --git a/docs/bilder/03-dynamisch.png b/docs/bilder/03-dynamisch.png new file mode 100644 index 0000000..44469f4 Binary files /dev/null and b/docs/bilder/03-dynamisch.png differ diff --git a/docs/bilder/04-meine-codes-liste.png b/docs/bilder/04-meine-codes-liste.png new file mode 100644 index 0000000..e02ede6 Binary files /dev/null and b/docs/bilder/04-meine-codes-liste.png differ diff --git a/docs/bilder/05-meine-codes-karten.png b/docs/bilder/05-meine-codes-karten.png new file mode 100644 index 0000000..3bdc1d2 Binary files /dev/null and b/docs/bilder/05-meine-codes-karten.png differ diff --git a/docs/bilder/06-ziel-bearbeiten.png b/docs/bilder/06-ziel-bearbeiten.png new file mode 100644 index 0000000..0392a19 Binary files /dev/null and b/docs/bilder/06-ziel-bearbeiten.png differ diff --git a/docs/bilder/07-landing-links.png b/docs/bilder/07-landing-links.png new file mode 100644 index 0000000..e3c1ceb Binary files /dev/null and b/docs/bilder/07-landing-links.png differ diff --git a/docs/bilder/08-landing-social.png b/docs/bilder/08-landing-social.png new file mode 100644 index 0000000..568ad60 Binary files /dev/null and b/docs/bilder/08-landing-social.png differ diff --git a/docs/bilder/09-landing-pdf.png b/docs/bilder/09-landing-pdf.png new file mode 100644 index 0000000..51e3fe7 Binary files /dev/null and b/docs/bilder/09-landing-pdf.png differ diff --git a/docs/bilder/09b-landing-feedback.png b/docs/bilder/09b-landing-feedback.png new file mode 100644 index 0000000..8b819a4 Binary files /dev/null and b/docs/bilder/09b-landing-feedback.png differ diff --git a/docs/bilder/10-feedback-auswertung.png b/docs/bilder/10-feedback-auswertung.png new file mode 100644 index 0000000..36b1b66 Binary files /dev/null and b/docs/bilder/10-feedback-auswertung.png differ diff --git a/docs/bilder/11-verwaltung.png b/docs/bilder/11-verwaltung.png new file mode 100644 index 0000000..5f8d58b Binary files /dev/null and b/docs/bilder/11-verwaltung.png differ diff --git a/docs/bilder/12-verwaltung-typen.png b/docs/bilder/12-verwaltung-typen.png new file mode 100644 index 0000000..f413836 Binary files /dev/null and b/docs/bilder/12-verwaltung-typen.png differ diff --git a/docs/bilder/13-verwaltung-gruppen.png b/docs/bilder/13-verwaltung-gruppen.png new file mode 100644 index 0000000..87c1291 Binary files /dev/null and b/docs/bilder/13-verwaltung-gruppen.png differ diff --git a/docs/bilder/14-verwaltung-domains.png b/docs/bilder/14-verwaltung-domains.png new file mode 100644 index 0000000..434dbbb Binary files /dev/null and b/docs/bilder/14-verwaltung-domains.png differ diff --git a/docs/bilder/15-konto.png b/docs/bilder/15-konto.png new file mode 100644 index 0000000..4f4b91f Binary files /dev/null and b/docs/bilder/15-konto.png differ diff --git a/docs/screenshots.mjs b/docs/screenshots.mjs new file mode 100644 index 0000000..bfc7894 --- /dev/null +++ b/docs/screenshots.mjs @@ -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<>endobj\n" + + "2 0 obj<>endobj\n" + + "3 0 obj<>endobj\n" + + "trailer<>\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}.`); diff --git a/package-lock.json b/package-lock.json index ea1cd16..039f195 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "dotenv": "^17.2.4", "eslint": "^9", "eslint-config-next": "16.3.0", + "playwright": "^1.62.1", "prisma": "^7.9.1", "tailwindcss": "^4", "typescript": "^5" @@ -5025,6 +5026,21 @@ "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": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -7095,6 +7111,38 @@ "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": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", diff --git a/package.json b/package.json index f838ccb..8a222e2 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "dotenv": "^17.2.4", "eslint": "^9", "eslint-config-next": "16.3.0", + "playwright": "^1.62.1", "prisma": "^7.9.1", "tailwindcss": "^4", "typescript": "^5"