Compare commits
3
Commits
v1.5.0
..
c06f87e6d9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c06f87e6d9 | ||
|
|
b96e9ddbe5 | ||
|
|
586e1f042f |
@@ -0,0 +1,74 @@
|
|||||||
|
# Änderungen
|
||||||
|
|
||||||
|
Was in welcher Fassung dazugekommen ist. Dieselbe Liste steht in der
|
||||||
|
Anwendung unter **Version und Änderungen**, erreichbar über die
|
||||||
|
Versionsnummer in der Fußzeile.
|
||||||
|
|
||||||
|
> Diese Datei wird erzeugt: `npm run changelog`.
|
||||||
|
> Gepflegt wird der Verlauf in `src/lib/changelog.ts`.
|
||||||
|
|
||||||
|
## 1.6.0 — 21.08.2026
|
||||||
|
|
||||||
|
Sichtbare Version mit Änderungsverlauf.
|
||||||
|
|
||||||
|
- Die laufende Version steht in der Fußzeile und führt auf eine Seite mit dem vollständigen Änderungsverlauf.
|
||||||
|
- Der Verlauf wird an einer Stelle gepflegt; die CHANGELOG.md im Projekt entsteht daraus.
|
||||||
|
- Die Roadmap verweist für Vergangenes auf den Changelog und behält den Blick nach vorn.
|
||||||
|
|
||||||
|
## 1.5.0 — 20.08.2026
|
||||||
|
|
||||||
|
Statische Codes lassen sich benennen und ablegen.
|
||||||
|
|
||||||
|
- Das Feld „Name“ gilt in beiden Betriebsarten, nicht mehr nur bei dynamischen Codes.
|
||||||
|
- Der Name bestimmt den Dateinamen beim Herunterladen.
|
||||||
|
- Ein statischer Code lässt sich in „Meine Codes“ ablegen — als Schnappschuss ohne Kurz-Link und ohne Scan-Zähler.
|
||||||
|
|
||||||
|
## 1.4.0 — 17.08.2026
|
||||||
|
|
||||||
|
Sprechende Kurz-Links und eine früh erkannte Fehlkonfiguration.
|
||||||
|
|
||||||
|
- Kurz-Links selbst vergeben und nachträglich ändern, mit „/“ als Trenner.
|
||||||
|
- Die vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — gedruckte Codes bleiben gültig.
|
||||||
|
- Vergeben und Ändern ist Administratoren vorbehalten und in der Verwaltung für Bearbeiter freigebbar.
|
||||||
|
- Weicht BASE_URL von der aufgerufenen Adresse ab, meldet die Anmeldung das sofort statt erst am Ende.
|
||||||
|
- Auf der Anmeldeseite steht nicht mehr, wie die Anmeldung konfiguriert ist.
|
||||||
|
|
||||||
|
## 1.3.0 — 16.08.2026
|
||||||
|
|
||||||
|
Eigenes Erscheinungsbild und ein Dark Mode.
|
||||||
|
|
||||||
|
- Name, Logo, Favicon und Akzentfarbe sind im Admin-Bereich einstellbar.
|
||||||
|
- Hell und dunkel folgen dem Betriebssystem, mit Umschalter für eine eigene Wahl.
|
||||||
|
- Das Update-Skript erklärt, warum ein Vorspulen scheitert, statt nur abzubrechen.
|
||||||
|
- Die Authentik-Anleitung sagt deutlicher, wie die Redirect URI zu bilden ist.
|
||||||
|
|
||||||
|
## 1.2.0 — 15.08.2026
|
||||||
|
|
||||||
|
Single Sign-on, Dokumentation und eine Sicherheitsdurchsicht.
|
||||||
|
|
||||||
|
- Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem ID-Token.
|
||||||
|
- Bedienungsanleitung mit Bildern, Funktionsübersicht und Roadmap.
|
||||||
|
- Anmeldung und Sitzungen nach OWASP durchgesehen und gehärtet: stärkere Passwort-Parameter, Längengrenze, Herkunftsprüfung, Schutzheader, absolute Sitzungsdauer.
|
||||||
|
- Auf der Startseite steht „nur dynamisch“ statt eines nichtssagenden Abzeichens an jeder Kachel.
|
||||||
|
|
||||||
|
## 1.1.0 — 14.08.2026
|
||||||
|
|
||||||
|
Alle fünfzehn QR-Typen, Anmeldung, Gruppen und mehrere Domains.
|
||||||
|
|
||||||
|
- Vierzehn weitere QR-Typen — von vCard und WLAN bis PDF, Bild und Feedback.
|
||||||
|
- Landingpages als eigene Gattung für Typen ohne fremdes Ziel.
|
||||||
|
- Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen.
|
||||||
|
- Anmeldung, Benutzer, Gruppen und die Rollen Administrator, Bearbeiter und Betrachter.
|
||||||
|
- Mehrere Domains für Kurz-URLs, den Gruppen zugeteilt.
|
||||||
|
- „Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter.
|
||||||
|
- Einzelne QR-Typen lassen sich in der Verwaltung ausblenden.
|
||||||
|
|
||||||
|
## 1.0.0 — 13.08.2026
|
||||||
|
|
||||||
|
Erste lauffähige Fassung.
|
||||||
|
|
||||||
|
- Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, Migrationen beim Start.
|
||||||
|
- QR-Typ Website, statisch und dynamisch, mit Kurz-URL, Weiterleitung und Scan-Zähler.
|
||||||
|
- Gestaltung mit Farben, Kontrastwarnung, Größe, Fehlerkorrektur und Rand.
|
||||||
|
- Download als PNG und SVG, serverseitig gerendert.
|
||||||
|
- Update-Skript für Linux, macOS und Windows.
|
||||||
@@ -18,6 +18,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
|
|||||||
sowie Feedback-Formulare — öffentlich, ohne einen Hinweis auf die Anwendung.
|
sowie Feedback-Formulare — öffentlich, ohne einen Hinweis auf die Anwendung.
|
||||||
- **Gestaltung:** freie Farben mit Kontrastwarnung, 256–2048 px,
|
- **Gestaltung:** freie Farben mit Kontrastwarnung, 256–2048 px,
|
||||||
Fehlerkorrektur L/M/Q/H, einstellbarer Rand. Download als PNG und SVG.
|
Fehlerkorrektur L/M/Q/H, einstellbarer Rand. Download als PNG und SVG.
|
||||||
|
- **Logo in der Mitte:** das Logo der Installation oder ein eigenes je Code,
|
||||||
|
10–25 % der Kantenlänge, mit erzwungener Fehlerkorrektur H.
|
||||||
- **Meine Codes:** Liste oder Karten, Suche, Sortierung, Gruppenfilter,
|
- **Meine Codes:** Liste oder Karten, Suche, Sortierung, Gruppenfilter,
|
||||||
Ziel ändern, löschen. Statische Codes lassen sich auf Wunsch mit Namen
|
Ziel ändern, löschen. Statische Codes lassen sich auf Wunsch mit Namen
|
||||||
ablegen, um sie wiederzufinden.
|
ablegen, um sie wiederzufinden.
|
||||||
@@ -34,6 +36,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
|
|||||||
eigene Wahl.
|
eigene Wahl.
|
||||||
- **Sprechende Kurz-Links:** `qr.firma.de/r/sommeraktion` statt Zufallszeichen,
|
- **Sprechende Kurz-Links:** `qr.firma.de/r/sommeraktion` statt Zufallszeichen,
|
||||||
nachträglich änderbar — die vorherige Adresse bleibt gültig.
|
nachträglich änderbar — die vorherige Adresse bleibt gültig.
|
||||||
|
- **Sichtbare Version:** Die Fußzeile nennt die laufende Fassung und führt
|
||||||
|
zum Änderungsverlauf — nach einem Update sofort erkennbar, ob es ankam.
|
||||||
- **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start
|
- **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start
|
||||||
von selbst; alles Nötige steht in einer `.env`.
|
von selbst; alles Nötige steht in einer `.env`.
|
||||||
|
|
||||||
@@ -48,7 +52,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
|
|||||||
- **[Anmeldung über Authentik (OIDC)](docs/authentik.md)** — Einrichtung,
|
- **[Anmeldung über Authentik (OIDC)](docs/authentik.md)** — Einrichtung,
|
||||||
Rollen und der Weg zurück
|
Rollen und der Weg zurück
|
||||||
- **[Alle Funktionen im Detail](docs/funktionen.md)** — die lange Fassung
|
- **[Alle Funktionen im Detail](docs/funktionen.md)** — die lange Fassung
|
||||||
- **[Roadmap](docs/roadmap.md)** — was gebaut ist und was kommen könnte
|
- **[Änderungen](CHANGELOG.md)** — was in welcher Version dazugekommen ist
|
||||||
|
- **[Roadmap](docs/roadmap.md)** — was als Nächstes kommen könnte
|
||||||
- **[Anmeldung und Sitzungen nach OWASP](docs/sicherheit.md)** — Durchsicht,
|
- **[Anmeldung und Sitzungen nach OWASP](docs/sicherheit.md)** — Durchsicht,
|
||||||
Härtungen und was bewusst offen bleibt
|
Härtungen und was bewusst offen bleibt
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,29 @@ Liste steht dort „statisch“ und ein Strich.
|
|||||||
Wer nach dem Ablegen den Inhalt ändert, bekommt das Ablegen erneut angeboten —
|
Wer nach dem Ablegen den Inhalt ändert, bekommt das Ablegen erneut angeboten —
|
||||||
der vorherige Eintrag bleibt als eigener Schnappschuss bestehen.
|
der vorherige Eintrag bleibt als eigener Schnappschuss bestehen.
|
||||||
|
|
||||||
|
### Ein Logo in der Mitte
|
||||||
|
|
||||||
|
Ein Bild in die Mitte des Codes setzen, wie man es von Firmen-Codes kennt.
|
||||||
|
Zur Wahl stehen zwei Quellen:
|
||||||
|
|
||||||
|
- das **Logo dieser Installation** aus dem Erscheinungsbild — ein Haken
|
||||||
|
genügt, kein Upload je Code;
|
||||||
|
- ein **eigenes Logo** je Code, für Kundenprojekte mit fremdem Zeichen.
|
||||||
|
|
||||||
|
Die Größe ist zwischen 10 und 25 % der Kantenlänge einstellbar; voreingestellt
|
||||||
|
sind 20 %, das entspricht rund 4 % der Fläche. Hinter dem Bild liegt eine
|
||||||
|
Fläche in der Hintergrundfarbe, damit es sich absetzt.
|
||||||
|
|
||||||
|
**Die Fehlerkorrektur wird dabei auf H erzwungen** — was das Bild verdeckt,
|
||||||
|
muss sie ausgleichen. Die Auswahl steht dann fest auf H und sagt auch warum;
|
||||||
|
heimlich geschieht das nicht. Ab 22 % erscheint eine Warnung: Die
|
||||||
|
Fehlerkorrektur trägt das zwar, aber manche Scanner-Apps suchen in der Mitte
|
||||||
|
nach Ausrichtungsmustern.
|
||||||
|
|
||||||
|
Jeder erzeugte Code wird in der Prüfung wieder eingelesen — über mehrere
|
||||||
|
Größen, Prozentsätze und Inhaltslängen. Das ersetzt trotzdem nicht den einen
|
||||||
|
Scan mit dem eigenen Handy, bevor etwas in den Druck geht.
|
||||||
|
|
||||||
### Der Kurz-Link
|
### Der Kurz-Link
|
||||||
|
|
||||||
Ohne eigene Angabe entsteht er aus acht Zeichen eines Alphabets ohne
|
Ohne eigene Angabe entsteht er aus acht Zeichen eines Alphabets ohne
|
||||||
@@ -500,6 +523,21 @@ alles zeigt und Typen aus späteren Versionen automatisch erscheinen.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Version und Änderungen
|
||||||
|
|
||||||
|
Die Fußzeile nennt die laufende Version und führt auf eine Seite mit dem
|
||||||
|
vollständigen Änderungsverlauf. Nach einem Update ist das die schnellste
|
||||||
|
Antwort auf die Frage, ob der Container wirklich mit dem neuen Abbild läuft:
|
||||||
|
Steht dort noch die alte Nummer, ist er es nicht.
|
||||||
|
|
||||||
|
Der Verlauf wird an **einer** Stelle gepflegt (`src/lib/changelog.ts`).
|
||||||
|
Daraus entstehen sowohl die Seite in der Anwendung als auch die
|
||||||
|
[CHANGELOG.md](../CHANGELOG.md) im Projekt (`npm run changelog`); eine
|
||||||
|
Prüfung wacht darüber, dass beides zusammenpasst. Zu jeder Version gibt es
|
||||||
|
einen Git-Tag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Schnittstelle
|
## Schnittstelle
|
||||||
|
|
||||||
Alles, was die Oberfläche tut, geht auch über HTTP:
|
Alles, was die Oberfläche tut, geht auch über HTTP:
|
||||||
|
|||||||
+9
-87
@@ -7,94 +7,16 @@ Aufwände sind grobe Einschätzungen, keine Zusagen.
|
|||||||
|
|
||||||
## Gebaut
|
## Gebaut
|
||||||
|
|
||||||
### Version 1 — Grundgerüst (13.08.2026)
|
Der vollständige Verlauf mit Versionsnummern steht im
|
||||||
|
[Changelog](../CHANGELOG.md) — und in der Anwendung selbst unter
|
||||||
|
**Version und Änderungen**, erreichbar über die Versionsnummer in der
|
||||||
|
Fußzeile.
|
||||||
|
|
||||||
- Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, ohne Reverse
|
Kurz gefasst: Version 1.0.0 (13.08.2026) brachte das Grundgerüst mit dem Typ
|
||||||
Proxy, Migrationen beim Start
|
Website, 1.1.0 alle fünfzehn Typen samt Anmeldung, Gruppen und Domains,
|
||||||
- QR-Typ-Registry als Dispatch-Tabelle — ein neuer Typ ist ein Modul plus ein
|
1.2.0 Single Sign-on und die Sicherheitsdurchsicht, 1.3.0 Erscheinungsbild
|
||||||
Formular, ohne Eingriff in den übrigen Code
|
und Dark Mode, 1.4.0 sprechende Kurz-Links, 1.5.0 benennbare statische Codes
|
||||||
- Typ **Website**, statisch und dynamisch, mit Kurz-URL, 302-Weiterleitung und
|
und 1.6.0 diesen Versionshinweis.
|
||||||
Scan-Zähler
|
|
||||||
- Gestaltung: Farben mit Kontrastwarnung, Größe, Fehlerkorrektur, Rand
|
|
||||||
- Download als PNG und SVG, serverseitig gerendert
|
|
||||||
- „Meine Codes“ mit Ziel ändern und Löschen
|
|
||||||
- Deutsche Oberfläche, deutsche Fehlermeldungen, Validierung mit Zod
|
|
||||||
- Update-Skript für Linux/macOS und Windows
|
|
||||||
|
|
||||||
### Typen einzeln freigeben (14.08.2026)
|
|
||||||
|
|
||||||
Ausblenden nicht gebrauchter Typen in der Verwaltung. Bereits erstellte Codes
|
|
||||||
eines abgeschalteten Typs funktionieren weiter.
|
|
||||||
|
|
||||||
### Phase A — acht weitere Typen (14.08.2026)
|
|
||||||
|
|
||||||
vCard, App-Link, WhatsApp, E-Mail, SMS, Standort, Zahlung (GiroCode und
|
|
||||||
Zahlungslink), WLAN.
|
|
||||||
|
|
||||||
### Phase B1 — Anmeldung, Gruppen, Rollen (14.08.2026)
|
|
||||||
|
|
||||||
Abschaltbare Anmeldung, Benutzerverwaltung, Gruppen mit den Rollen
|
|
||||||
Administrator, Bearbeiter und Betrachter, Sitzungen in der Datenbank,
|
|
||||||
scrypt-Passwörter, Sperre nach Fehlversuchen.
|
|
||||||
|
|
||||||
### Phase B2 — mehrere Domains (14.08.2026)
|
|
||||||
|
|
||||||
Domains für Kurz-URLs, den Gruppen zugeteilt. Slug je Domain eindeutig,
|
|
||||||
Host-Header gegen die registrierten Domains geprüft.
|
|
||||||
|
|
||||||
### Listenansicht (14.08.2026)
|
|
||||||
|
|
||||||
„Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter — gedacht für
|
|
||||||
viele Projekte nebeneinander. Karten bleiben als zweite Ansicht.
|
|
||||||
|
|
||||||
### Phase C — die letzten sechs Typen (14.08.2026)
|
|
||||||
|
|
||||||
Mehrere Links, Social Media, Video, PDF, Bild, Feedback. Dazu:
|
|
||||||
|
|
||||||
- Landingpages als eigene Gattung (`/s/{slug}`), ohne App-Navigation
|
|
||||||
- Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen
|
|
||||||
- Feedback mit anonymer Erhebung und Auswertung
|
|
||||||
|
|
||||||
Damit sind **alle 15 vorgesehenen Typen** gebaut.
|
|
||||||
|
|
||||||
### Single Sign-on (15.08.2026)
|
|
||||||
|
|
||||||
Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem
|
|
||||||
ID-Token.
|
|
||||||
|
|
||||||
### Erscheinungsbild (16.08.2026)
|
|
||||||
|
|
||||||
Name, Logo, Favicon und Akzentfarbe im Admin-Bereich einstellbar. Die Farbe
|
|
||||||
wirkt über die Farbvariablen, die Tailwind ohnehin benutzt — deshalb ohne
|
|
||||||
Umbau der rund 60 eingefärbten Stellen.
|
|
||||||
|
|
||||||
### Dark Mode (16.08.2026)
|
|
||||||
|
|
||||||
Folgt dem Betriebssystem, mit Umschalter für Hell, Dunkel und System.
|
|
||||||
Umgesetzt über `light-dark()` statt über `dark:`-Klassen — deshalb ohne
|
|
||||||
Änderung an den rund 440 Farbklassen und ohne JavaScript für die Systemwahl.
|
|
||||||
|
|
||||||
### Sprechende Kurz-Links (17.08.2026)
|
|
||||||
|
|
||||||
Kurz-Links selbst vergeben und nachträglich ändern, mit `/` als Trenner. Die
|
|
||||||
vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — sonst würde
|
|
||||||
jedes Umbenennen gedruckte Codes entwerten. Standardmäßig Administratoren
|
|
||||||
vorbehalten, per Einstellung für Bearbeiter freigebbar.
|
|
||||||
|
|
||||||
Schließt [Issue #1](https://git.wilde-server.de/florian.wilde/qrcode-generator/issues/1).
|
|
||||||
|
|
||||||
### Statische Codes benennen und ablegen (20.08.2026)
|
|
||||||
|
|
||||||
Das Namensfeld gilt in beiden Betriebsarten. Es bestimmt den Dateinamen beim
|
|
||||||
Herunterladen und erlaubt, einen statischen Code in „Meine Codes" abzulegen —
|
|
||||||
als Schnappschuss, dessen Inhalt sich nicht mehr ändert.
|
|
||||||
|
|
||||||
Schließt [Issue #2](https://git.wilde-server.de/florian.wilde/qrcode-generator/issues/2).
|
|
||||||
|
|
||||||
### Dokumentation (15.08.2026)
|
|
||||||
|
|
||||||
Bedienungsanleitung mit Bildern, Authentik-Anleitung, Funktionsübersicht.
|
|
||||||
Die Bilder erzeugt ein Skript, damit sie nicht veralten.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Generated
+69
-6
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "qrcode-generator",
|
"name": "qrcode-generator",
|
||||||
"version": "1.1.0",
|
"version": "1.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "qrcode-generator",
|
"name": "qrcode-generator",
|
||||||
"version": "1.1.0",
|
"version": "1.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.9.1",
|
"@prisma/adapter-pg": "^7.9.1",
|
||||||
"@prisma/client": "^7.9.1",
|
"@prisma/client": "^7.9.1",
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -566,7 +567,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -2065,6 +2065,72 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/wasi-threads": "1.2.2",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@tybys/wasm-util": "^0.10.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emnapi/core": "^1.7.1",
|
||||||
|
"@emnapi/runtime": "^1.7.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||||
|
"version": "0.10.2",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"dev": true,
|
||||||
|
"inBundle": true,
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.3.3",
|
"version": "4.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
||||||
@@ -4039,7 +4105,6 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -7795,7 +7860,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||||
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/colour": "^1.1.0",
|
"@img/colour": "^1.1.0",
|
||||||
"detect-libc": "^2.1.2",
|
"detect-libc": "^2.1.2",
|
||||||
@@ -7845,7 +7909,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"optional": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
},
|
},
|
||||||
|
|||||||
+3
-1
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "qrcode-generator",
|
"name": "qrcode-generator",
|
||||||
"version": "1.1.0",
|
"version": "1.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
|
"changelog": "node scripts/changelog.mjs",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Ein Logo in der Mitte des Codes, wie man es von Firmen-Codes kennt.
|
||||||
|
--
|
||||||
|
-- Drei Angaben gehoeren zur Gestaltung: woher das Bild kommt, welche Datei es
|
||||||
|
-- ist und wie gross es sein soll. Ohne Herkunft gibt es kein Logo — deshalb
|
||||||
|
-- sind die ersten beiden Spalten leer erlaubt und ohne Vorbelegung.
|
||||||
|
ALTER TABLE "QrCode" ADD COLUMN "logoSource" TEXT;
|
||||||
|
ALTER TABLE "QrCode" ADD COLUMN "logoUploadId" TEXT;
|
||||||
|
|
||||||
|
-- Die Groesse hat eine Vorbelegung, damit bestehende Codes einen gueltigen
|
||||||
|
-- Wert tragen. Ohne Logo bleibt er wirkungslos.
|
||||||
|
ALTER TABLE "QrCode" ADD COLUMN "logoScale" INTEGER NOT NULL DEFAULT 20;
|
||||||
@@ -182,6 +182,14 @@ model QrCode {
|
|||||||
size Int @default(512)
|
size Int @default(512)
|
||||||
ecLevel String @default("M")
|
ecLevel String @default("M")
|
||||||
margin Int @default(4)
|
margin Int @default(4)
|
||||||
|
/// Logo in der Mitte: "branding" nimmt das Logo der Installation,
|
||||||
|
/// "upload" eine eigene Datei. Leer heisst: kein Logo.
|
||||||
|
logoSource String?
|
||||||
|
/// Die eigene Datei, wenn `logoSource` = "upload". Sie haengt ueber
|
||||||
|
/// `Upload.qrCodeId` am Code und verschwindet mit ihm.
|
||||||
|
logoUploadId String?
|
||||||
|
/// Kantenlaenge des Logos in Prozent der Kantenlaenge des Codes.
|
||||||
|
logoScale Int @default(20)
|
||||||
scans Int @default(0)
|
scans Int @default(0)
|
||||||
lastScanAt DateTime?
|
lastScanAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Schreibt CHANGELOG.md aus src/lib/changelog.ts.
|
||||||
|
*
|
||||||
|
* node scripts/changelog.mjs erzeugen
|
||||||
|
* node scripts/changelog.mjs --check nur prüfen, ob die Datei aktuell ist
|
||||||
|
*
|
||||||
|
* Warum erzeugt und nicht von Hand gepflegt: Die Anwendung zeigt denselben
|
||||||
|
* Verlauf unter /version an. Zwei gepflegte Listen liefen unweigerlich
|
||||||
|
* auseinander — und ein Changelog, der nicht stimmt, ist schlimmer als keiner.
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
const wurzel = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const ziel = join(wurzel, "CHANGELOG.md");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Quelle ist TypeScript, dieses Skript aber schlichtes Node. Statt einen
|
||||||
|
* Übersetzer dazuzunehmen, wird der Datenteil gelesen und ausgewertet — er
|
||||||
|
* besteht ausschließlich aus Zeichenketten und Listen.
|
||||||
|
*/
|
||||||
|
function ladeVerlauf() {
|
||||||
|
const quelle = readFileSync(join(wurzel, "src", "lib", "changelog.ts"), "utf8");
|
||||||
|
const anfang = quelle.indexOf("export const CHANGELOG");
|
||||||
|
if (anfang === -1) throw new Error("CHANGELOG-Liste nicht gefunden.");
|
||||||
|
|
||||||
|
const start = quelle.indexOf("[", anfang);
|
||||||
|
const ende = quelle.indexOf("\n];", start);
|
||||||
|
if (start === -1 || ende === -1) throw new Error("Liste nicht abgrenzbar.");
|
||||||
|
|
||||||
|
const literal = quelle.slice(start, ende + 2);
|
||||||
|
return Function(`"use strict"; return (${literal});`)();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatiereDatum(iso) {
|
||||||
|
const [jahr, monat, tag] = iso.split("-");
|
||||||
|
return `${tag}.${monat}.${jahr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function baue(verlauf) {
|
||||||
|
const zeilen = [
|
||||||
|
"# Änderungen",
|
||||||
|
"",
|
||||||
|
"Was in welcher Fassung dazugekommen ist. Dieselbe Liste steht in der",
|
||||||
|
"Anwendung unter **Version und Änderungen**, erreichbar über die",
|
||||||
|
"Versionsnummer in der Fußzeile.",
|
||||||
|
"",
|
||||||
|
"> Diese Datei wird erzeugt: `npm run changelog`.",
|
||||||
|
"> Gepflegt wird der Verlauf in `src/lib/changelog.ts`.",
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const eintrag of verlauf) {
|
||||||
|
zeilen.push(`## ${eintrag.version} — ${formatiereDatum(eintrag.date)}`, "");
|
||||||
|
zeilen.push(eintrag.summary, "");
|
||||||
|
for (const änderung of eintrag.changes) zeilen.push(`- ${änderung}`);
|
||||||
|
zeilen.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return zeilen.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const inhalt = baue(ladeVerlauf());
|
||||||
|
|
||||||
|
if (process.argv.includes("--check")) {
|
||||||
|
let vorhanden = "";
|
||||||
|
try {
|
||||||
|
vorhanden = readFileSync(ziel, "utf8");
|
||||||
|
} catch {
|
||||||
|
console.error("CHANGELOG.md fehlt. Erzeugen mit: npm run changelog");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (vorhanden !== inhalt) {
|
||||||
|
console.error(
|
||||||
|
"CHANGELOG.md passt nicht mehr zu src/lib/changelog.ts. Neu erzeugen mit: npm run changelog",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log("CHANGELOG.md ist aktuell.");
|
||||||
|
} else {
|
||||||
|
writeFileSync(ziel, inhalt, "utf8");
|
||||||
|
console.log("CHANGELOG.md geschrieben.");
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { notFound } from "next/navigation";
|
|||||||
import { Icon } from "@/components/icons";
|
import { Icon } from "@/components/icons";
|
||||||
import { QrGenerator } from "@/components/qr-generator";
|
import { QrGenerator } from "@/components/qr-generator";
|
||||||
import { resolveTargetGroup } from "@/lib/auth/groups";
|
import { resolveTargetGroup } from "@/lib/auth/groups";
|
||||||
|
import { getBranding } from "@/lib/branding";
|
||||||
import { can } from "@/lib/auth/policy";
|
import { can } from "@/lib/auth/policy";
|
||||||
import { getSettings } from "@/lib/settings";
|
import { getSettings } from "@/lib/settings";
|
||||||
import { requirePageActor } from "@/lib/auth/guard";
|
import { requirePageActor } from "@/lib/auth/guard";
|
||||||
@@ -85,5 +86,15 @@ export default async function CreatePage({ params }: Props) {
|
|||||||
const groupId = await resolveTargetGroup(actor, undefined).catch(() => undefined);
|
const groupId = await resolveTargetGroup(actor, undefined).catch(() => undefined);
|
||||||
const maySetSlug = can(actor, "codes:slug", { groupId, editorsMaySetSlug });
|
const maySetSlug = can(actor, "codes:slug", { groupId, editorsMaySetSlug });
|
||||||
|
|
||||||
return <QrGenerator typeId={state.qrType.id} maySetSlug={maySetSlug} />;
|
// Ohne hinterlegtes Logo entfaellt die Wahl der Herkunft — dann bleibt nur
|
||||||
|
// der eigene Upload.
|
||||||
|
const { logoUploadId } = await getBranding();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QrGenerator
|
||||||
|
typeId={state.qrType.id}
|
||||||
|
maySetSlug={maySetSlug}
|
||||||
|
hasBrandingLogo={logoUploadId !== null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ThemeToggle } from "@/components/theme-toggle";
|
|||||||
import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor";
|
import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor";
|
||||||
import { can } from "@/lib/auth/policy";
|
import { can } from "@/lib/auth/policy";
|
||||||
import { getBranding } from "@/lib/branding";
|
import { getBranding } from "@/lib/branding";
|
||||||
|
import { APP_VERSION } from "@/lib/changelog";
|
||||||
|
|
||||||
// Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert
|
// Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert
|
||||||
// werden.
|
// werden.
|
||||||
@@ -128,9 +129,20 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
|
|||||||
<main className="flex-1">{children}</main>
|
<main className="flex-1">{children}</main>
|
||||||
|
|
||||||
<footer className="border-t border-slate-200 bg-surface">
|
<footer className="border-t border-slate-200 bg-surface">
|
||||||
<div className="mx-auto max-w-6xl px-4 py-6 text-sm text-slate-500 sm:px-6">
|
<div className="mx-auto flex max-w-6xl flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-4 py-6 text-sm text-slate-500 sm:px-6">
|
||||||
{branding.appName}
|
<span>
|
||||||
{branding.footerText ? ` · ${branding.footerText}` : null}
|
{branding.appName}
|
||||||
|
{branding.footerText ? ` · ${branding.footerText}` : null}
|
||||||
|
</span>
|
||||||
|
{/* Welche Fassung hier läuft — nach einem Update die schnellste
|
||||||
|
Antwort auf die Frage, ob das Update auch angekommen ist. */}
|
||||||
|
<Link
|
||||||
|
href="/version"
|
||||||
|
title="Version und Änderungen"
|
||||||
|
className="rounded font-mono text-xs tabular-nums transition-colors hover:text-slate-900"
|
||||||
|
>
|
||||||
|
v{APP_VERSION}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import { Badge, Card } from "@/components/ui";
|
||||||
|
import { requirePageActor } from "@/lib/auth/guard";
|
||||||
|
import {
|
||||||
|
APP_VERSION,
|
||||||
|
CHANGELOG,
|
||||||
|
formatChangelogDate,
|
||||||
|
} from "@/lib/changelog";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Version und Änderungen" };
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function VersionPage() {
|
||||||
|
await requirePageActor();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
|
||||||
|
>
|
||||||
|
<Icon name="arrowLeft" className="size-4" />
|
||||||
|
Zur Typ-Auswahl
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-7">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||||
|
Version und Änderungen
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-slate-600">
|
||||||
|
Hier läuft Version{" "}
|
||||||
|
<span className="font-semibold text-slate-900">{APP_VERSION}</span>.
|
||||||
|
Darunter steht, was in welcher Fassung dazugekommen ist.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
|
Nach einem Update lohnt ein Blick hierher: Steht oben noch die alte
|
||||||
|
Nummer, läuft der Container noch mit dem alten Abbild.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol className="space-y-4">
|
||||||
|
{CHANGELOG.map((eintrag) => {
|
||||||
|
const laeuft = eintrag.version === APP_VERSION;
|
||||||
|
return (
|
||||||
|
<li key={eintrag.version}>
|
||||||
|
<Card>
|
||||||
|
<div className="mb-3 flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">
|
||||||
|
{eintrag.version}
|
||||||
|
</h2>
|
||||||
|
<span className="text-sm text-slate-500 tabular-nums">
|
||||||
|
{formatChangelogDate(eintrag.date)}
|
||||||
|
</span>
|
||||||
|
{laeuft ? (
|
||||||
|
<Badge tone="accent">läuft hier</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mb-3 text-slate-600">{eintrag.summary}</p>
|
||||||
|
|
||||||
|
<ul className="space-y-1.5 text-sm text-slate-700">
|
||||||
|
{eintrag.changes.map((zeile) => (
|
||||||
|
<li key={zeile} className="flex gap-2.5">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="mt-2 size-1.5 shrink-0 rounded-full bg-slate-300"
|
||||||
|
/>
|
||||||
|
<span>{zeile}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { requireImplementedQrType } from "@/lib/qr/availability";
|
|||||||
import { errorResponse } from "@/lib/http";
|
import { errorResponse } from "@/lib/http";
|
||||||
import { designSchema } from "@/lib/qr/design";
|
import { designSchema } from "@/lib/qr/design";
|
||||||
import { getQrType } from "@/lib/qr/registry";
|
import { getQrType } from "@/lib/qr/registry";
|
||||||
|
import { ladeLogo } from "@/lib/qr/logo";
|
||||||
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
|
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
|
||||||
import { downloadFilename, QrRequestError } from "@/lib/qr/request";
|
import { downloadFilename, QrRequestError } from "@/lib/qr/request";
|
||||||
|
|
||||||
@@ -48,7 +49,11 @@ export async function GET(
|
|||||||
size: code.size,
|
size: code.size,
|
||||||
ecLevel: code.ecLevel,
|
ecLevel: code.ecLevel,
|
||||||
margin: code.margin,
|
margin: code.margin,
|
||||||
|
logoSource: code.logoSource,
|
||||||
|
logoUploadId: code.logoUploadId,
|
||||||
|
logoScale: code.logoScale,
|
||||||
});
|
});
|
||||||
|
const logo = await ladeLogo(design);
|
||||||
|
|
||||||
// Was im Bild steckt, haengt an der Betriebsart: Ein dynamischer Code
|
// Was im Bild steckt, haengt an der Betriebsart: Ein dynamischer Code
|
||||||
// traegt die Kurz-URL, ein statischer seinen Inhalt selbst.
|
// traegt die Kurz-URL, ein statischer seinen Inhalt selbst.
|
||||||
@@ -58,8 +63,8 @@ export async function GET(
|
|||||||
: shortUrl(code.slug, code.domain.hostname);
|
: shortUrl(code.slug, code.domain.hostname);
|
||||||
const body =
|
const body =
|
||||||
format === "png"
|
format === "png"
|
||||||
? new Uint8Array(await renderPng(content, design))
|
? new Uint8Array(await renderPng(content, design, logo))
|
||||||
: await renderSvg(content, design);
|
: await renderSvg(content, design, logo);
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": CONTENT_TYPES[format],
|
"Content-Type": CONTENT_TYPES[format],
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { assertDynamicPossible } from "@/lib/qr/request";
|
|||||||
import { describeSlugProblem, normalizeSlug } from "@/lib/slug";
|
import { describeSlugProblem, normalizeSlug } from "@/lib/slug";
|
||||||
import { assertSlugAvailable } from "@/lib/slug-resolve";
|
import { assertSlugAvailable } from "@/lib/slug-resolve";
|
||||||
import { getSettings } from "@/lib/settings";
|
import { getSettings } from "@/lib/settings";
|
||||||
import { attachUploads } from "@/lib/uploads";
|
import { attachUpload, attachUploads, detachUpload } from "@/lib/uploads";
|
||||||
import { QrRequestError } from "@/lib/qr/type-module";
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -133,6 +133,18 @@ export async function PATCH(
|
|||||||
Object.assign(update, parsed.data.design);
|
Object.assign(update, parsed.data.design);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ein eigenes Logo gehört zur Gestaltung, nicht zum Inhalt. Es muss
|
||||||
|
// trotzdem an den Code gehängt werden — sonst liegt die Datei herrenlos
|
||||||
|
// herum und wird nach einem Tag weggeräumt, mitsamt dem Logo im Code.
|
||||||
|
const logoVorher =
|
||||||
|
existing.logoSource === "upload" ? existing.logoUploadId : null;
|
||||||
|
const logoNachher =
|
||||||
|
parsed.data.design === undefined
|
||||||
|
? logoVorher
|
||||||
|
: parsed.data.design.logoSource === "upload"
|
||||||
|
? parsed.data.design.logoUploadId
|
||||||
|
: null;
|
||||||
|
|
||||||
/* ------------------------------------------------------- Kurz-Link */
|
/* ------------------------------------------------------- Kurz-Link */
|
||||||
|
|
||||||
const neuerSlug = parsed.data.slug?.trim();
|
const neuerSlug = parsed.data.slug?.trim();
|
||||||
@@ -225,7 +237,18 @@ export async function PATCH(
|
|||||||
// Eine ausgetauschte Datei verliert dabei ihren Bezug zum Code und wird
|
// Eine ausgetauschte Datei verliert dabei ihren Bezug zum Code und wird
|
||||||
// später als verwaist weggeräumt.
|
// später als verwaist weggeräumt.
|
||||||
if (neueDateien !== null) {
|
if (neueDateien !== null) {
|
||||||
await attachUploads(updated.id, neueDateien);
|
await attachUploads(
|
||||||
|
updated.id,
|
||||||
|
logoNachher ? [...neueDateien, logoNachher] : neueDateien,
|
||||||
|
);
|
||||||
|
} else if (logoNachher && logoNachher !== logoVorher) {
|
||||||
|
await attachUpload(updated.id, logoNachher);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein abgelegtes oder ausgetauschtes Logo verliert seinen Bezug und wird
|
||||||
|
// später weggeräumt.
|
||||||
|
if (logoVorher && logoVorher !== logoNachher) {
|
||||||
|
await detachUpload(updated.id, logoVorher);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.json({ code: toQrCodeDto(updated) });
|
return Response.json({ code: toQrCodeDto(updated) });
|
||||||
|
|||||||
@@ -45,6 +45,24 @@ const createSchema = z.object({
|
|||||||
domainId: z.string().trim().min(1).optional(),
|
domainId: z.string().trim().min(1).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Dateien, die zu einem Code gehören.
|
||||||
|
*
|
||||||
|
* Das sind die des Typ-Moduls — ein PDF, ein Bild — **und** ein eigenes Logo
|
||||||
|
* aus der Gestaltung. Wird das vergessen, löst `attachUploads` das Logo beim
|
||||||
|
* nächsten Speichern wieder vom Code und räumt es später als herrenlos weg.
|
||||||
|
*/
|
||||||
|
function dateienDesCodes(
|
||||||
|
qrType: { uploadIds: (data: never) => string[] },
|
||||||
|
data: unknown,
|
||||||
|
design: { logoSource: string | null; logoUploadId: string | null },
|
||||||
|
): string[] {
|
||||||
|
const ids = qrType.uploadIds(data as never);
|
||||||
|
return design.logoSource === "upload" && design.logoUploadId
|
||||||
|
? [...ids, design.logoUploadId]
|
||||||
|
: ids;
|
||||||
|
}
|
||||||
|
|
||||||
function isUniqueViolation(error: unknown): boolean {
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
return (
|
return (
|
||||||
typeof error === "object" &&
|
typeof error === "object" &&
|
||||||
@@ -160,7 +178,7 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
|
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await attachUploads(created.id, qrType.uploadIds(validated.data));
|
await attachUploads(created.id, dateienDesCodes(qrType, validated.data, design));
|
||||||
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +205,7 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
});
|
});
|
||||||
// Erst jetzt gehören die hochgeladenen Dateien zu diesem Code —
|
// Erst jetzt gehören die hochgeladenen Dateien zu diesem Code —
|
||||||
// vorher lagen sie ohne Bezug herum.
|
// vorher lagen sie ohne Bezug herum.
|
||||||
await attachUploads(created.id, qrType.uploadIds(validated.data));
|
await attachUploads(created.id, dateienDesCodes(qrType, validated.data, design));
|
||||||
|
|
||||||
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { getActor, requireSignedIn } from "@/lib/auth/actor";
|
import { getActor, requireSignedIn } from "@/lib/auth/actor";
|
||||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { ladeLogo } from "@/lib/qr/logo";
|
||||||
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
|
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
|
||||||
import {
|
import {
|
||||||
downloadFilename,
|
downloadFilename,
|
||||||
@@ -38,10 +39,11 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
|
|
||||||
const { qrType, data, content } = await resolveQrRequest(parsed.data);
|
const { qrType, data, content } = await resolveQrRequest(parsed.data);
|
||||||
|
|
||||||
|
const logo = await ladeLogo(design);
|
||||||
const body =
|
const body =
|
||||||
format === "png"
|
format === "png"
|
||||||
? new Uint8Array(await renderPng(content, design))
|
? new Uint8Array(await renderPng(content, design, logo))
|
||||||
: await renderSvg(content, design);
|
: await renderSvg(content, design, logo);
|
||||||
|
|
||||||
return new Response(body, {
|
return new Response(body, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { NextRequest } from "next/server";
|
|||||||
|
|
||||||
import { getActor, requireSignedIn } from "@/lib/auth/actor";
|
import { getActor, requireSignedIn } from "@/lib/auth/actor";
|
||||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { ladeLogo } from "@/lib/qr/logo";
|
||||||
import { renderSvg } from "@/lib/qr/render";
|
import { renderSvg } from "@/lib/qr/render";
|
||||||
import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request";
|
import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request";
|
||||||
|
|
||||||
@@ -21,7 +22,8 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { content } = await resolveQrRequest(parsed.data);
|
const { content } = await resolveQrRequest(parsed.data);
|
||||||
const svg = await renderSvg(content, parsed.data.design);
|
const logo = await ladeLogo(parsed.data.design);
|
||||||
|
const svg = await renderSvg(content, parsed.data.design, logo);
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
content,
|
content,
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Icon } from "@/components/icons";
|
import { Icon } from "@/components/icons";
|
||||||
|
import { FileField } from "@/components/qr-forms/file-field";
|
||||||
import { Field, Notice, TextInput, cx } from "@/components/ui";
|
import { Field, Notice, TextInput, cx } from "@/components/ui";
|
||||||
import { evaluateContrast } from "@/lib/qr/contrast";
|
import { evaluateContrast } from "@/lib/qr/contrast";
|
||||||
import {
|
import {
|
||||||
COLOR_PRESETS,
|
COLOR_PRESETS,
|
||||||
EC_LEVELS,
|
EC_LEVELS,
|
||||||
EC_LEVEL_LABELS,
|
EC_LEVEL_LABELS,
|
||||||
|
LOGO_SCALE_WARN,
|
||||||
|
MAX_LOGO_SCALE,
|
||||||
MAX_SIZE,
|
MAX_SIZE,
|
||||||
|
MIN_LOGO_SCALE,
|
||||||
MIN_SIZE,
|
MIN_SIZE,
|
||||||
type EcLevel,
|
type EcLevel,
|
||||||
|
type LogoSource,
|
||||||
type QrDesign,
|
type QrDesign,
|
||||||
} from "@/lib/qr/design";
|
} from "@/lib/qr/design";
|
||||||
|
|
||||||
@@ -62,9 +67,12 @@ function ColorField({
|
|||||||
export function DesignPanel({
|
export function DesignPanel({
|
||||||
design,
|
design,
|
||||||
onChange,
|
onChange,
|
||||||
|
/** Ist unter „Erscheinungsbild“ ein Logo hinterlegt? Sonst entfällt die Wahl. */
|
||||||
|
hasBrandingLogo = false,
|
||||||
}: {
|
}: {
|
||||||
design: QrDesign;
|
design: QrDesign;
|
||||||
onChange: (design: QrDesign) => void;
|
onChange: (design: QrDesign) => void;
|
||||||
|
hasBrandingLogo?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const patch = (changes: Partial<QrDesign>) => onChange({ ...design, ...changes });
|
const patch = (changes: Partial<QrDesign>) => onChange({ ...design, ...changes });
|
||||||
const contrast = evaluateContrast(design.fgColor, design.bgColor);
|
const contrast = evaluateContrast(design.fgColor, design.bgColor);
|
||||||
@@ -165,7 +173,11 @@ export function DesignPanel({
|
|||||||
|
|
||||||
<Field
|
<Field
|
||||||
label="Fehlerkorrektur"
|
label="Fehlerkorrektur"
|
||||||
hint={EC_LEVEL_LABELS[design.ecLevel]}
|
hint={
|
||||||
|
design.logoSource === null
|
||||||
|
? EC_LEVEL_LABELS[design.ecLevel]
|
||||||
|
: "Mit Logo gilt H — was das Bild verdeckt, muss die Fehlerkorrektur ausgleichen."
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
@@ -177,11 +189,14 @@ export function DesignPanel({
|
|||||||
key={level}
|
key={level}
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={design.ecLevel === level}
|
aria-checked={
|
||||||
|
design.logoSource === null ? design.ecLevel === level : level === "H"
|
||||||
|
}
|
||||||
|
disabled={design.logoSource !== null}
|
||||||
onClick={() => patch({ ecLevel: level })}
|
onClick={() => patch({ ecLevel: level })}
|
||||||
className={cx(
|
className={cx(
|
||||||
"rounded-lg border py-2 text-sm font-medium transition-colors",
|
"rounded-lg border py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
design.ecLevel === level
|
(design.logoSource === null ? design.ecLevel : "H") === level
|
||||||
? "border-indigo-600 bg-accent-soft text-accent-soft-fg"
|
? "border-indigo-600 bg-accent-soft text-accent-soft-fg"
|
||||||
: "border-slate-300 bg-surface text-slate-600 hover:bg-slate-50",
|
: "border-slate-300 bg-surface text-slate-600 hover:bg-slate-50",
|
||||||
)}
|
)}
|
||||||
@@ -208,6 +223,147 @@ export function DesignPanel({
|
|||||||
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600"
|
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<LogoAuswahl
|
||||||
|
design={design}
|
||||||
|
patch={patch}
|
||||||
|
hasBrandingLogo={hasBrandingLogo}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------- Logo */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Bild in der Mitte des Codes.
|
||||||
|
*
|
||||||
|
* Bewusst ein eigener Abschnitt und nicht bloß ein Feld: Ein Logo zieht drei
|
||||||
|
* Entscheidungen nach sich — woher es kommt, wie groß es wird, und dass die
|
||||||
|
* Fehlerkorrektur dafür hochgesetzt wird. Das gehört sichtbar beieinander.
|
||||||
|
*/
|
||||||
|
function LogoAuswahl({
|
||||||
|
design,
|
||||||
|
patch,
|
||||||
|
hasBrandingLogo,
|
||||||
|
}: {
|
||||||
|
design: QrDesign;
|
||||||
|
patch: (changes: Partial<QrDesign>) => void;
|
||||||
|
hasBrandingLogo: boolean;
|
||||||
|
}) {
|
||||||
|
const an = design.logoSource !== null;
|
||||||
|
|
||||||
|
const waehle = (quelle: LogoSource | null) => {
|
||||||
|
if (quelle === null) {
|
||||||
|
patch({ logoSource: null, logoUploadId: null });
|
||||||
|
} else if (quelle === "branding") {
|
||||||
|
patch({ logoSource: "branding", logoUploadId: null });
|
||||||
|
} else {
|
||||||
|
patch({ logoSource: "upload" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset className="border-t border-slate-200 pt-4">
|
||||||
|
<legend className="pr-2 text-xs font-semibold tracking-wide text-slate-500 uppercase">
|
||||||
|
Logo in der Mitte
|
||||||
|
</legend>
|
||||||
|
|
||||||
|
<label className="mt-3 flex cursor-pointer items-start gap-2.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={an}
|
||||||
|
onChange={(event) =>
|
||||||
|
waehle(
|
||||||
|
event.target.checked ? (hasBrandingLogo ? "branding" : "upload") : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="mt-0.5 size-4 shrink-0 rounded border-slate-300 accent-indigo-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-700">
|
||||||
|
Ein Logo in die Mitte des Codes setzen
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{an ? (
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
{hasBrandingLogo ? (
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Herkunft des Logos"
|
||||||
|
className="space-y-2"
|
||||||
|
>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["branding", "Logo dieser Installation"],
|
||||||
|
["upload", "Eigenes Logo hochladen"],
|
||||||
|
] as [LogoSource, string][]
|
||||||
|
).map(([quelle, text]) => (
|
||||||
|
<label
|
||||||
|
key={quelle}
|
||||||
|
className="flex cursor-pointer items-center gap-2.5"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="logo-quelle"
|
||||||
|
checked={design.logoSource === quelle}
|
||||||
|
onChange={() => waehle(quelle)}
|
||||||
|
className="size-4 shrink-0 accent-indigo-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-700">{text}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{design.logoSource === "upload" ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FileField
|
||||||
|
kind="qrlogo"
|
||||||
|
label={design.logoUploadId ? "Anderes Logo wählen" : "Logo wählen"}
|
||||||
|
onUploaded={(datei) => patch({ logoUploadId: datei.fileId })}
|
||||||
|
/>
|
||||||
|
{design.logoUploadId ? null : (
|
||||||
|
<p className="text-sm text-amber-700">
|
||||||
|
Noch keine Datei gewählt — bis dahin bleibt die Mitte frei.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={`Größe: ${design.logoScale} % der Kantenlänge`}
|
||||||
|
htmlFor="qr-logo-scale"
|
||||||
|
hint="Bezogen auf die Breite des Codes. Bei 20 % sind rund 4 % der Fläche verdeckt."
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="qr-logo-scale"
|
||||||
|
type="range"
|
||||||
|
min={MIN_LOGO_SCALE}
|
||||||
|
max={MAX_LOGO_SCALE}
|
||||||
|
step={1}
|
||||||
|
value={design.logoScale}
|
||||||
|
onChange={(event) => patch({ logoScale: Number(event.target.value) })}
|
||||||
|
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{design.logoScale > LOGO_SCALE_WARN ? (
|
||||||
|
<Notice tone="warning">
|
||||||
|
Ab {LOGO_SCALE_WARN} % wird es knapp. Die Fehlerkorrektur trägt das
|
||||||
|
zwar, aber manche Scanner-Apps suchen in der Mitte nach
|
||||||
|
Ausrichtungsmustern. Prüfe den fertigen Code mit mehr als einer App,
|
||||||
|
bevor er in den Druck geht.
|
||||||
|
</Notice>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Hinter dem Logo liegt eine Fläche in der Hintergrundfarbe, damit es
|
||||||
|
sich absetzt. Ein Code mit Logo gehört vor dem Druck einmal
|
||||||
|
eingescannt — das ist die einzige Prüfung, die wirklich zählt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,9 +47,12 @@ export function QrGenerator({
|
|||||||
typeId,
|
typeId,
|
||||||
/** Darf der Kurz-Link selbst vergeben werden? Sonst erscheint das Feld nicht. */
|
/** Darf der Kurz-Link selbst vergeben werden? Sonst erscheint das Feld nicht. */
|
||||||
maySetSlug = true,
|
maySetSlug = true,
|
||||||
|
/** Ist ein Logo der Installation hinterlegt? Sonst nur eigener Upload. */
|
||||||
|
hasBrandingLogo = false,
|
||||||
}: {
|
}: {
|
||||||
typeId: QrTypeId;
|
typeId: QrTypeId;
|
||||||
maySetSlug?: boolean;
|
maySetSlug?: boolean;
|
||||||
|
hasBrandingLogo?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const qrType = qrTypes[typeId];
|
const qrType = qrTypes[typeId];
|
||||||
const Form = qrForms[typeId];
|
const Form = qrForms[typeId];
|
||||||
@@ -510,7 +513,11 @@ export function QrGenerator({
|
|||||||
<CardTitle hint="Farben, Größe und Fehlerkorrektur gelten für Vorschau und Download.">
|
<CardTitle hint="Farben, Größe und Fehlerkorrektur gelten für Vorschau und Download.">
|
||||||
Gestaltung
|
Gestaltung
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<DesignPanel design={design} onChange={setDesign} />
|
<DesignPanel
|
||||||
|
design={design}
|
||||||
|
onChange={setDesign}
|
||||||
|
hasBrandingLogo={hasBrandingLogo}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import pkg from "../../package.json";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Änderungsverlauf der Anwendung — die einzige Quelle dafür.
|
||||||
|
*
|
||||||
|
* Hieraus entsteht beides: die Seite unter `/version` und die `CHANGELOG.md`
|
||||||
|
* im Wurzelverzeichnis (`npm run changelog`). Zwei gepflegte Listen liefen
|
||||||
|
* unweigerlich auseinander; eine Prüfung wacht darüber, dass die erzeugte
|
||||||
|
* Datei zu dieser Quelle passt.
|
||||||
|
*
|
||||||
|
* Die Einträge sind aus dem Abschnitt „Gebaut“ der Roadmap und der
|
||||||
|
* Git-Historie zusammengetragen. Versionsnummern gab es dabei nie — es
|
||||||
|
* existieren keine Tags —, deshalb ist je **Erscheinungstag** eine Version
|
||||||
|
* gebildet. Das ist nachträglich vergeben, aber die Daten selbst stimmen.
|
||||||
|
*/
|
||||||
|
export type ChangelogEntry = {
|
||||||
|
version: string;
|
||||||
|
/** ISO-Datum, an dem der Stand entstanden ist. */
|
||||||
|
date: string;
|
||||||
|
/** Eine Zeile zur Einordnung — was diese Version ausmacht. */
|
||||||
|
summary: string;
|
||||||
|
changes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CHANGELOG: readonly ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "1.6.0",
|
||||||
|
date: "2026-08-21",
|
||||||
|
summary: "Sichtbare Version mit Änderungsverlauf.",
|
||||||
|
changes: [
|
||||||
|
"Die laufende Version steht in der Fußzeile und führt auf eine Seite mit dem vollständigen Änderungsverlauf.",
|
||||||
|
"Der Verlauf wird an einer Stelle gepflegt; die CHANGELOG.md im Projekt entsteht daraus.",
|
||||||
|
"Die Roadmap verweist für Vergangenes auf den Changelog und behält den Blick nach vorn.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "1.5.0",
|
||||||
|
date: "2026-08-20",
|
||||||
|
summary: "Statische Codes lassen sich benennen und ablegen.",
|
||||||
|
changes: [
|
||||||
|
"Das Feld „Name“ gilt in beiden Betriebsarten, nicht mehr nur bei dynamischen Codes.",
|
||||||
|
"Der Name bestimmt den Dateinamen beim Herunterladen.",
|
||||||
|
"Ein statischer Code lässt sich in „Meine Codes“ ablegen — als Schnappschuss ohne Kurz-Link und ohne Scan-Zähler.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "1.4.0",
|
||||||
|
date: "2026-08-17",
|
||||||
|
summary: "Sprechende Kurz-Links und eine früh erkannte Fehlkonfiguration.",
|
||||||
|
changes: [
|
||||||
|
"Kurz-Links selbst vergeben und nachträglich ändern, mit „/“ als Trenner.",
|
||||||
|
"Die vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — gedruckte Codes bleiben gültig.",
|
||||||
|
"Vergeben und Ändern ist Administratoren vorbehalten und in der Verwaltung für Bearbeiter freigebbar.",
|
||||||
|
"Weicht BASE_URL von der aufgerufenen Adresse ab, meldet die Anmeldung das sofort statt erst am Ende.",
|
||||||
|
"Auf der Anmeldeseite steht nicht mehr, wie die Anmeldung konfiguriert ist.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "1.3.0",
|
||||||
|
date: "2026-08-16",
|
||||||
|
summary: "Eigenes Erscheinungsbild und ein Dark Mode.",
|
||||||
|
changes: [
|
||||||
|
"Name, Logo, Favicon und Akzentfarbe sind im Admin-Bereich einstellbar.",
|
||||||
|
"Hell und dunkel folgen dem Betriebssystem, mit Umschalter für eine eigene Wahl.",
|
||||||
|
"Das Update-Skript erklärt, warum ein Vorspulen scheitert, statt nur abzubrechen.",
|
||||||
|
"Die Authentik-Anleitung sagt deutlicher, wie die Redirect URI zu bilden ist.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "1.2.0",
|
||||||
|
date: "2026-08-15",
|
||||||
|
summary: "Single Sign-on, Dokumentation und eine Sicherheitsdurchsicht.",
|
||||||
|
changes: [
|
||||||
|
"Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem ID-Token.",
|
||||||
|
"Bedienungsanleitung mit Bildern, Funktionsübersicht und Roadmap.",
|
||||||
|
"Anmeldung und Sitzungen nach OWASP durchgesehen und gehärtet: stärkere Passwort-Parameter, Längengrenze, Herkunftsprüfung, Schutzheader, absolute Sitzungsdauer.",
|
||||||
|
"Auf der Startseite steht „nur dynamisch“ statt eines nichtssagenden Abzeichens an jeder Kachel.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "1.1.0",
|
||||||
|
date: "2026-08-14",
|
||||||
|
summary: "Alle fünfzehn QR-Typen, Anmeldung, Gruppen und mehrere Domains.",
|
||||||
|
changes: [
|
||||||
|
"Vierzehn weitere QR-Typen — von vCard und WLAN bis PDF, Bild und Feedback.",
|
||||||
|
"Landingpages als eigene Gattung für Typen ohne fremdes Ziel.",
|
||||||
|
"Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen.",
|
||||||
|
"Anmeldung, Benutzer, Gruppen und die Rollen Administrator, Bearbeiter und Betrachter.",
|
||||||
|
"Mehrere Domains für Kurz-URLs, den Gruppen zugeteilt.",
|
||||||
|
"„Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter.",
|
||||||
|
"Einzelne QR-Typen lassen sich in der Verwaltung ausblenden.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "1.0.0",
|
||||||
|
date: "2026-08-13",
|
||||||
|
summary: "Erste lauffähige Fassung.",
|
||||||
|
changes: [
|
||||||
|
"Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, Migrationen beim Start.",
|
||||||
|
"QR-Typ Website, statisch und dynamisch, mit Kurz-URL, Weiterleitung und Scan-Zähler.",
|
||||||
|
"Gestaltung mit Farben, Kontrastwarnung, Größe, Fehlerkorrektur und Rand.",
|
||||||
|
"Download als PNG und SVG, serverseitig gerendert.",
|
||||||
|
"Update-Skript für Linux, macOS und Windows.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die laufende Version.
|
||||||
|
*
|
||||||
|
* Aus der `package.json`, damit es genau eine Stelle gibt, an der sie steht.
|
||||||
|
* Der Wert wird beim Bauen eingesetzt — im Container liegt kein Git, aus dem
|
||||||
|
* sich sonst etwas ableiten ließe.
|
||||||
|
*/
|
||||||
|
export const APP_VERSION: string = pkg.version;
|
||||||
|
|
||||||
|
/** Der Eintrag zur laufenden Version, falls er im Verlauf steht. */
|
||||||
|
export function currentEntry(): ChangelogEntry | undefined {
|
||||||
|
return CHANGELOG.find((eintrag) => eintrag.version === APP_VERSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Datum als „21.08.2026“ — ohne Abhängigkeit von der Zeitzone des Servers. */
|
||||||
|
export function formatChangelogDate(iso: string): string {
|
||||||
|
const [jahr, monat, tag] = iso.split("-");
|
||||||
|
return `${tag}.${monat}.${jahr}`;
|
||||||
|
}
|
||||||
+4
-1
@@ -3,7 +3,7 @@ import "server-only";
|
|||||||
import type { Prisma, QrCode } from "@/generated/prisma/client";
|
import type { Prisma, QrCode } from "@/generated/prisma/client";
|
||||||
import type { QrCodeDto } from "@/lib/dto";
|
import type { QrCodeDto } from "@/lib/dto";
|
||||||
import { landingUrl, shortUrl } from "@/lib/env";
|
import { landingUrl, shortUrl } from "@/lib/env";
|
||||||
import type { EcLevel } from "@/lib/qr/design";
|
import type { EcLevel, LogoSource } from "@/lib/qr/design";
|
||||||
import { getQrType } from "@/lib/qr/registry";
|
import { getQrType } from "@/lib/qr/registry";
|
||||||
import type { QrTypeId } from "@/lib/qr/type-module";
|
import type { QrTypeId } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
@@ -83,6 +83,9 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
|
|||||||
size: code.size,
|
size: code.size,
|
||||||
ecLevel: code.ecLevel as EcLevel,
|
ecLevel: code.ecLevel as EcLevel,
|
||||||
margin: code.margin,
|
margin: code.margin,
|
||||||
|
logoSource: code.logoSource as LogoSource | null,
|
||||||
|
logoUploadId: code.logoUploadId,
|
||||||
|
logoScale: code.logoScale,
|
||||||
},
|
},
|
||||||
scans: code.scans,
|
scans: code.scans,
|
||||||
lastScanAt: code.lastScanAt?.toISOString() ?? null,
|
lastScanAt: code.lastScanAt?.toISOString() ?? null,
|
||||||
|
|||||||
+4
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { EcLevel } from "@/lib/qr/design";
|
import type { EcLevel, LogoSource } from "@/lib/qr/design";
|
||||||
import type { QrTypeId } from "@/lib/qr/type-module";
|
import type { QrTypeId } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
/** Statisch oder dynamisch — bestimmt, was ein Code überhaupt kann. */
|
/** Statisch oder dynamisch — bestimmt, was ein Code überhaupt kann. */
|
||||||
@@ -35,6 +35,9 @@ export type QrCodeDto = {
|
|||||||
size: number;
|
size: number;
|
||||||
ecLevel: EcLevel;
|
ecLevel: EcLevel;
|
||||||
margin: number;
|
margin: number;
|
||||||
|
logoSource: LogoSource | null;
|
||||||
|
logoUploadId: string | null;
|
||||||
|
logoScale: number;
|
||||||
};
|
};
|
||||||
scans: number;
|
scans: number;
|
||||||
lastScanAt: string | null;
|
lastScanAt: string | null;
|
||||||
|
|||||||
+55
-1
@@ -13,6 +13,29 @@ export const EC_LEVEL_LABELS: Record<EcLevel, string> = {
|
|||||||
export const MIN_SIZE = 256;
|
export const MIN_SIZE = 256;
|
||||||
export const MAX_SIZE = 2048;
|
export const MAX_SIZE = 2048;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logo in der Mitte des Codes.
|
||||||
|
*
|
||||||
|
* `branding` nimmt das Logo der Installation aus dem Erscheinungsbild,
|
||||||
|
* `upload` ein eigenes, das zu diesem Code gehört. Ohne Angabe kein Logo.
|
||||||
|
*/
|
||||||
|
export const LOGO_SOURCES = ["branding", "upload"] as const;
|
||||||
|
export type LogoSource = (typeof LOGO_SOURCES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kantenlänge des Logos in Prozent der Kantenlänge des Codes.
|
||||||
|
*
|
||||||
|
* Bei 20 % sind knapp 4 % der Fläche verdeckt — das steckt die
|
||||||
|
* Fehlerkorrektur H (30 %) mühelos weg. Die Obergrenze liegt bei 25 %, weil
|
||||||
|
* darüber weniger die Fehlerkorrektur das Problem ist als die Scanner-Apps:
|
||||||
|
* Manche suchen in der Mitte nach Ausrichtungsmustern.
|
||||||
|
*/
|
||||||
|
export const MIN_LOGO_SCALE = 10;
|
||||||
|
export const MAX_LOGO_SCALE = 25;
|
||||||
|
/** Ab hier ein Hinweis, dass es knapp wird. */
|
||||||
|
export const LOGO_SCALE_WARN = 22;
|
||||||
|
export const DEFAULT_LOGO_SCALE = 20;
|
||||||
|
|
||||||
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
||||||
|
|
||||||
const hexColor = z
|
const hexColor = z
|
||||||
@@ -39,7 +62,25 @@ export const designSchema = z.object({
|
|||||||
.int({ error: "Der Rand muss eine ganze Zahl sein." })
|
.int({ error: "Der Rand muss eine ganze Zahl sein." })
|
||||||
.min(0, { error: "Der Rand darf nicht negativ sein." })
|
.min(0, { error: "Der Rand darf nicht negativ sein." })
|
||||||
.max(16, { error: "Der Rand darf höchstens 16 Module breit sein." }),
|
.max(16, { error: "Der Rand darf höchstens 16 Module breit sein." }),
|
||||||
});
|
logoSource: z
|
||||||
|
.enum(LOGO_SOURCES, { error: "Unbekannte Herkunft des Logos." })
|
||||||
|
.nullable()
|
||||||
|
.default(null),
|
||||||
|
logoUploadId: z.string().trim().min(1).nullable().default(null),
|
||||||
|
logoScale: z.coerce
|
||||||
|
.number({ error: "Die Logogröße muss eine Zahl sein." })
|
||||||
|
.int({ error: "Die Logogröße muss eine ganze Zahl sein." })
|
||||||
|
.min(MIN_LOGO_SCALE, {
|
||||||
|
error: `Das Logo muss mindestens ${MIN_LOGO_SCALE} % breit sein.`,
|
||||||
|
})
|
||||||
|
.max(MAX_LOGO_SCALE, {
|
||||||
|
error: `Das Logo darf höchstens ${MAX_LOGO_SCALE} % breit sein — darüber wird der Code unzuverlässig.`,
|
||||||
|
})
|
||||||
|
.default(DEFAULT_LOGO_SCALE),
|
||||||
|
}).refine(
|
||||||
|
(design) => design.logoSource !== "upload" || design.logoUploadId !== null,
|
||||||
|
{ error: "Für ein eigenes Logo fehlt die hochgeladene Datei." },
|
||||||
|
);
|
||||||
|
|
||||||
export type QrDesign = z.infer<typeof designSchema>;
|
export type QrDesign = z.infer<typeof designSchema>;
|
||||||
|
|
||||||
@@ -49,8 +90,21 @@ export const defaultDesign: QrDesign = {
|
|||||||
size: 512,
|
size: 512,
|
||||||
ecLevel: "M",
|
ecLevel: "M",
|
||||||
margin: 4,
|
margin: 4,
|
||||||
|
logoSource: null,
|
||||||
|
logoUploadId: null,
|
||||||
|
logoScale: DEFAULT_LOGO_SCALE,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Logo verdeckt einen Teil des Codes. Was fehlt, muss die Fehlerkorrektur
|
||||||
|
* ausgleichen — deshalb gilt mit Logo immer **H**, unabhängig davon, was in
|
||||||
|
* der Auswahl steht. Die Oberfläche sagt das dazu; heimlich geschieht hier
|
||||||
|
* nichts.
|
||||||
|
*/
|
||||||
|
export function effectiveEcLevel(design: QrDesign): EcLevel {
|
||||||
|
return design.logoSource === null ? design.ecLevel : "H";
|
||||||
|
}
|
||||||
|
|
||||||
/** Farbvorschläge für die schnelle Auswahl in der Oberfläche. */
|
/** Farbvorschläge für die schnelle Auswahl in der Oberfläche. */
|
||||||
export const COLOR_PRESETS: ReadonlyArray<{
|
export const COLOR_PRESETS: ReadonlyArray<{
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { getBranding } from "@/lib/branding";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import type { QrDesign } from "@/lib/qr/design";
|
||||||
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
|
/** Das Bild, das in die Mitte des Codes kommt. */
|
||||||
|
export type QrLogo = {
|
||||||
|
bytes: Buffer;
|
||||||
|
mimeType: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt das Logo, das die Gestaltung verlangt — oder `null`.
|
||||||
|
*
|
||||||
|
* Zwei Quellen: das Logo der Installation aus dem Erscheinungsbild, oder eine
|
||||||
|
* Datei, die zu diesem Code hochgeladen wurde. Beides liegt als Upload in der
|
||||||
|
* Datenbank.
|
||||||
|
*
|
||||||
|
* Fehlt die Datei, ist das kein stiller Ausfall: Ein Code, der ohne Logo
|
||||||
|
* herauskommt, obwohl eines eingestellt war, fiele erst nach dem Druck auf.
|
||||||
|
*/
|
||||||
|
export async function ladeLogo(design: QrDesign): Promise<QrLogo | null> {
|
||||||
|
if (design.logoSource === null) return null;
|
||||||
|
|
||||||
|
const id =
|
||||||
|
design.logoSource === "branding"
|
||||||
|
? (await getBranding()).logoUploadId
|
||||||
|
: design.logoUploadId;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
design.logoSource === "branding"
|
||||||
|
? "Für diese Installation ist kein Logo hinterlegt — unter Verwaltung → Erscheinungsbild lässt sich eines hochladen."
|
||||||
|
: "Das hochgeladene Logo wurde nicht gefunden. Bitte lade es erneut hoch.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const datei = await prisma.upload.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { data: true, mimeType: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!datei) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Das Logo wurde nicht gefunden. Bitte lade es erneut hoch.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bytes: Buffer.from(datei.data), mimeType: datei.mimeType };
|
||||||
|
}
|
||||||
+103
-4
@@ -1,8 +1,10 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import QRCode, { type QRCodeRenderersOptions } from "qrcode";
|
import QRCode, { type QRCodeRenderersOptions } from "qrcode";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
import type { QrDesign } from "@/lib/qr/design";
|
import { effectiveEcLevel, type QrDesign } from "@/lib/qr/design";
|
||||||
|
import type { QrLogo } from "@/lib/qr/logo";
|
||||||
import { UserFacingError } from "@/lib/qr/type-module";
|
import { UserFacingError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
/** Fehler beim Erzeugen des Codes, mit Meldung für die Oberfläche. */
|
/** Fehler beim Erzeugen des Codes, mit Meldung für die Oberfläche. */
|
||||||
@@ -10,7 +12,8 @@ export class QrRenderError extends UserFacingError {}
|
|||||||
|
|
||||||
function toOptions(design: QrDesign): QRCodeRenderersOptions {
|
function toOptions(design: QrDesign): QRCodeRenderersOptions {
|
||||||
return {
|
return {
|
||||||
errorCorrectionLevel: design.ecLevel,
|
// Mit Logo gilt immer H — siehe `effectiveEcLevel`.
|
||||||
|
errorCorrectionLevel: effectiveEcLevel(design),
|
||||||
margin: design.margin,
|
margin: design.margin,
|
||||||
width: design.size,
|
width: design.size,
|
||||||
color: {
|
color: {
|
||||||
@@ -30,12 +33,103 @@ function wrap(error: unknown): never {
|
|||||||
throw new QrRenderError(`Der QR-Code konnte nicht erzeugt werden: ${detail}`);
|
throw new QrRenderError(`Der QR-Code konnte nicht erzeugt werden: ${detail}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------- Logo */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie das Logo in den Code kommt.
|
||||||
|
*
|
||||||
|
* Hinter dem Bild liegt eine Fläche in der Hintergrundfarbe — ohne sie stünde
|
||||||
|
* das Logo auf den Modulen und ginge optisch unter. Der Rand dieser Fläche
|
||||||
|
* ist ein Zehntel der Logobreite; das reicht, um es abzusetzen, ohne mehr
|
||||||
|
* Code zu verdecken als nötig.
|
||||||
|
*
|
||||||
|
* Das Bild selbst wird eingepasst, nicht beschnitten (`contain`): Ein
|
||||||
|
* Firmenlogo im Querformat soll als Ganzes zu sehen sein.
|
||||||
|
*/
|
||||||
|
const PAD_ANTEIL = 0.1;
|
||||||
|
|
||||||
|
/** Kantenlänge von Bild und Fläche, in Bildpunkten. */
|
||||||
|
function logoMasse(gesamt: number, prozent: number) {
|
||||||
|
const bild = Math.round((gesamt * prozent) / 100);
|
||||||
|
const flaeche = Math.round(bild * (1 + 2 * PAD_ANTEIL));
|
||||||
|
return { bild, flaeche, versatz: Math.round((gesamt - flaeche) / 2) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function legeLogoAuf(
|
||||||
|
qrPng: Buffer,
|
||||||
|
design: QrDesign,
|
||||||
|
logo: QrLogo,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const { bild, flaeche, versatz } = logoMasse(design.size, design.logoScale);
|
||||||
|
|
||||||
|
// Erst das Bild auf die Fläche einpassen, dann die Fläche auf den Code.
|
||||||
|
// Zwei Schritte, weil `extend` den Rand in der Hintergrundfarbe zieht — das
|
||||||
|
// ergibt die Fläche, ohne eine zweite Zeichnung zu brauchen.
|
||||||
|
const rand = Math.round((flaeche - bild) / 2);
|
||||||
|
const kachel = await sharp(logo.bytes)
|
||||||
|
.resize(bild, bild, { fit: "contain", background: design.bgColor })
|
||||||
|
.extend({
|
||||||
|
top: rand,
|
||||||
|
bottom: flaeche - bild - rand,
|
||||||
|
left: rand,
|
||||||
|
right: flaeche - bild - rand,
|
||||||
|
background: design.bgColor,
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
return sharp(qrPng)
|
||||||
|
.composite([{ input: kachel, top: versatz, left: versatz }])
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt das Logo in ein fertiges QR-SVG.
|
||||||
|
*
|
||||||
|
* Das SVG von `qrcode` hat ein `viewBox` in Modulen — gerechnet wird deshalb
|
||||||
|
* in Modulen und nicht in Bildpunkten. Eingefügt wird direkt vor `</svg>`,
|
||||||
|
* damit Fläche und Bild über den Modulen liegen.
|
||||||
|
*/
|
||||||
|
function legeLogoInSvg(svg: string, design: QrDesign, logo: QrLogo): string {
|
||||||
|
const treffer = /viewBox="0 0 (\d+(?:\.\d+)?) /.exec(svg);
|
||||||
|
if (!treffer) {
|
||||||
|
// Ohne viewBox ließe sich nichts sauber platzieren. Lieber ohne Logo
|
||||||
|
// ausliefern als an einer falschen Stelle.
|
||||||
|
console.error("QR-SVG ohne viewBox — Logo wird ausgelassen.");
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kante = Number(treffer[1]);
|
||||||
|
const bild = (kante * design.logoScale) / 100;
|
||||||
|
const flaeche = bild * (1 + 2 * PAD_ANTEIL);
|
||||||
|
const versatz = (kante - flaeche) / 2;
|
||||||
|
const bildVersatz = (kante - bild) / 2;
|
||||||
|
|
||||||
|
const daten = `data:${logo.mimeType};base64,${logo.bytes.toString("base64")}`;
|
||||||
|
const rund = (wert: number) => Number(wert.toFixed(3));
|
||||||
|
|
||||||
|
const einsatz =
|
||||||
|
`<rect x="${rund(versatz)}" y="${rund(versatz)}" width="${rund(flaeche)}" height="${rund(flaeche)}" fill="${design.bgColor}"/>` +
|
||||||
|
`<image x="${rund(bildVersatz)}" y="${rund(bildVersatz)}" width="${rund(bild)}" height="${rund(bild)}" ` +
|
||||||
|
`preserveAspectRatio="xMidYMid meet" href="${daten}"/>`;
|
||||||
|
|
||||||
|
return svg.replace("</svg>", `${einsatz}</svg>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- Ausgabe */
|
||||||
|
|
||||||
export async function renderSvg(
|
export async function renderSvg(
|
||||||
content: string,
|
content: string,
|
||||||
design: QrDesign,
|
design: QrDesign,
|
||||||
|
logo: QrLogo | null = null,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
try {
|
try {
|
||||||
return await QRCode.toString(content, { ...toOptions(design), type: "svg" });
|
const svg = await QRCode.toString(content, {
|
||||||
|
...toOptions(design),
|
||||||
|
type: "svg",
|
||||||
|
});
|
||||||
|
return logo === null ? svg : legeLogoInSvg(svg, design, logo);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
wrap(error);
|
wrap(error);
|
||||||
}
|
}
|
||||||
@@ -44,9 +138,14 @@ export async function renderSvg(
|
|||||||
export async function renderPng(
|
export async function renderPng(
|
||||||
content: string,
|
content: string,
|
||||||
design: QrDesign,
|
design: QrDesign,
|
||||||
|
logo: QrLogo | null = null,
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
try {
|
try {
|
||||||
return await QRCode.toBuffer(content, { ...toOptions(design), type: "png" });
|
const png = await QRCode.toBuffer(content, {
|
||||||
|
...toOptions(design),
|
||||||
|
type: "png",
|
||||||
|
});
|
||||||
|
return logo === null ? png : await legeLogoAuf(png, design, logo);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
wrap(error);
|
wrap(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,16 @@ export const UPLOAD_KINDS = {
|
|||||||
// das dann bei jedem Seitenaufruf mitgeht.
|
// das dann bei jedem Seitenaufruf mitgeht.
|
||||||
maxBytes: 2 * 1024 * 1024,
|
maxBytes: 2 * 1024 * 1024,
|
||||||
},
|
},
|
||||||
|
qrlogo: {
|
||||||
|
label: "Logo im Code",
|
||||||
|
// Ohne GIF: Ein bewegtes Bild wird beim Einsetzen ohnehin zum Standbild,
|
||||||
|
// und die Auswahl soll nicht mehr versprechen, als hinten herauskommt.
|
||||||
|
mimeTypes: ["image/png", "image/jpeg", "image/webp", "image/avif"],
|
||||||
|
accept: "image/png,image/jpeg,image/webp,image/avif",
|
||||||
|
// Das Logo landet auf wenigen hundert Bildpunkten in der Mitte des Codes.
|
||||||
|
// Mehr als zwei Megabyte bringen dort nichts.
|
||||||
|
maxBytes: 2 * 1024 * 1024,
|
||||||
|
},
|
||||||
favicon: {
|
favicon: {
|
||||||
label: "Favicon",
|
label: "Favicon",
|
||||||
// Zusätzlich ICO — viele haben genau das noch herumliegen.
|
// Zusätzlich ICO — viele haben genau das noch herumliegen.
|
||||||
|
|||||||
@@ -189,6 +189,36 @@ export async function attachUploads(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hängt **eine** Datei an einen Code, ohne die übrigen anzurühren.
|
||||||
|
*
|
||||||
|
* Gebraucht für das Logo aus der Gestaltung: Ändert sich nur das Aussehen
|
||||||
|
* eines Codes, sollen die Dateien seines Inhalts unberührt bleiben.
|
||||||
|
*/
|
||||||
|
export async function attachUpload(
|
||||||
|
qrCodeId: string,
|
||||||
|
uploadId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await prisma.upload.updateMany({
|
||||||
|
// Dieselbe Bedingung wie oben: keine fremde Datei an sich ziehen.
|
||||||
|
where: { id: uploadId, OR: [{ qrCodeId: null }, { qrCodeId }] },
|
||||||
|
data: { qrCodeId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löst eine Datei wieder vom Code — sie wird danach als herrenlos weggeräumt.
|
||||||
|
*/
|
||||||
|
export async function detachUpload(
|
||||||
|
qrCodeId: string,
|
||||||
|
uploadId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await prisma.upload.updateMany({
|
||||||
|
where: { id: uploadId, qrCodeId },
|
||||||
|
data: { qrCodeId: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Wie lange eine hochgeladene, aber nie gespeicherte Datei liegen bleibt. */
|
/** Wie lange eine hochgeladene, aber nie gespeicherte Datei liegen bleibt. */
|
||||||
const ORPHAN_HOURS = 24;
|
const ORPHAN_HOURS = 24;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user