Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b96e9ddbe5 | ||
|
|
586e1f042f | ||
|
|
7f67a35218 | ||
|
|
eeaf3820b7 |
@@ -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.
|
||||||
@@ -19,7 +19,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
|
|||||||
- **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.
|
||||||
- **Meine Codes:** Liste oder Karten, Suche, Sortierung, Gruppenfilter,
|
- **Meine Codes:** Liste oder Karten, Suche, Sortierung, Gruppenfilter,
|
||||||
Ziel ändern, löschen.
|
Ziel ändern, löschen. Statische Codes lassen sich auf Wunsch mit Namen
|
||||||
|
ablegen, um sie wiederzufinden.
|
||||||
- **Feedback auswerten:** Durchschnitt, Verteilung und Kommentare — anonym
|
- **Feedback auswerten:** Durchschnitt, Verteilung und Kommentare — anonym
|
||||||
erhoben, ohne Name und IP-Adresse.
|
erhoben, ohne Name und IP-Adresse.
|
||||||
- **Anmeldung abschaltbar.** Mit Anmeldung: Benutzer, Gruppen und die Rollen
|
- **Anmeldung abschaltbar.** Mit Anmeldung: Benutzer, Gruppen und die Rollen
|
||||||
@@ -33,6 +34,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`.
|
||||||
|
|
||||||
@@ -47,7 +50,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
|
||||||
|
|
||||||
|
|||||||
+34
-1
@@ -102,7 +102,25 @@ Browser landet.
|
|||||||
| Ziel später änderbar | nein | ja, jederzeit |
|
| Ziel später änderbar | nein | ja, jederzeit |
|
||||||
| Scans gezählt | nein | ja, mit Datum des letzten |
|
| Scans gezählt | nein | ja, mit Datum des letzten |
|
||||||
| Braucht diesen Server | nie | dauerhaft |
|
| Braucht diesen Server | nie | dauerhaft |
|
||||||
| In der Datenbank | nichts | Slug, Typ, Daten, Gestaltung, Zähler |
|
| In „Meine Codes“ | auf Wunsch, als Schnappschuss | immer |
|
||||||
|
|
||||||
|
### Ein statischer Code mit Namen
|
||||||
|
|
||||||
|
Beide Betriebsarten haben ein Feld **Name (optional)**. Es tut zweierlei:
|
||||||
|
|
||||||
|
- Es bestimmt den **Dateinamen beim Herunterladen** — aus „Plakat Eingang“
|
||||||
|
wird `qr-plakat-eingang.png` statt `qr-example-com-plakat.png`.
|
||||||
|
- Auf Wunsch lässt sich der Code damit **in „Meine Codes“ ablegen**, um ihn
|
||||||
|
wiederzufinden und erneut herunterzuladen.
|
||||||
|
|
||||||
|
Ein abgelegter statischer Code ist ein **Schnappschuss**, kein steuerbares
|
||||||
|
Ziel. Änderbar ist nur sein Name; Inhalt und Gestaltung bleiben, wie sie beim
|
||||||
|
Ablegen waren — denn genau das steckt im gedruckten Bild. Er hat keinen
|
||||||
|
Kurz-Link, ist unter `/r/…` nicht erreichbar und zählt keine Scans; in der
|
||||||
|
Liste steht dort „statisch“ und ein Strich.
|
||||||
|
|
||||||
|
Wer nach dem Ablegen den Inhalt ändert, bekommt das Ablegen erneut angeboten —
|
||||||
|
der vorherige Eintrag bleibt als eigener Schnappschuss bestehen.
|
||||||
|
|
||||||
### Der Kurz-Link
|
### Der Kurz-Link
|
||||||
|
|
||||||
@@ -482,6 +500,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
-79
@@ -7,86 +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).
|
|
||||||
|
|
||||||
### Dokumentation (15.08.2026)
|
|
||||||
|
|
||||||
Bedienungsanleitung mit Bildern, Authentik-Anleitung, Funktionsübersicht.
|
|
||||||
Die Bilder erzeugt ein Skript, damit sie nicht veralten.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+2
-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",
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Statische Codes lassen sich mit Namen ablegen, damit man sie wiederfindet
|
||||||
|
-- und erneut herunterladen kann.
|
||||||
|
|
||||||
|
-- Alles, was es bisher gibt, ist dynamisch.
|
||||||
|
ALTER TABLE "QrCode" ADD COLUMN "mode" TEXT NOT NULL DEFAULT 'dynamic';
|
||||||
|
CREATE INDEX "QrCode_mode_idx" ON "QrCode"("mode");
|
||||||
|
|
||||||
|
-- Ein statischer Code hat keinen Kurz-Link: Er traegt seinen Inhalt selbst und
|
||||||
|
-- fuehrt auf nichts, was dieser Server ausliefert. In PostgreSQL schliessen
|
||||||
|
-- sich mehrere NULL in einem eindeutigen Index nicht aus — die Bedingung
|
||||||
|
-- „je Domain eindeutig" bleibt fuer dynamische Codes also unveraendert.
|
||||||
|
ALTER TABLE "QrCode" ALTER COLUMN "slug" DROP NOT NULL;
|
||||||
+12
-3
@@ -162,10 +162,18 @@ model QrTypeSetting {
|
|||||||
/// eigenes Payload-Format in `data` ab, ohne Schemaänderung.
|
/// eigenes Payload-Format in `data` ab, ohne Schemaänderung.
|
||||||
model QrCode {
|
model QrCode {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
/// Nur je Domain eindeutig — derselbe sprechende Slug darf auf zwei
|
/// Statisch oder dynamisch. Ein statischer Code traegt seinen Inhalt selbst
|
||||||
/// Domains liegen. Weil eine Domain mehreren Gruppen dienen kann, gilt
|
/// und wird nur abgelegt, damit man ihn wiederfindet und erneut
|
||||||
|
/// herunterladen kann — er hat weder Kurz-Link noch Scan-Zaehler.
|
||||||
|
mode String @default("dynamic")
|
||||||
|
/// Kurz-Link. Nur je Domain eindeutig — derselbe sprechende Slug darf auf
|
||||||
|
/// zwei Domains liegen. Weil eine Domain mehreren Gruppen dienen kann, gilt
|
||||||
/// diese Eindeutigkeit über Gruppengrenzen hinweg.
|
/// diese Eindeutigkeit über Gruppengrenzen hinweg.
|
||||||
slug String
|
///
|
||||||
|
/// Leer bei statischen Codes: Die fuehren auf nichts, was dieser Server
|
||||||
|
/// ausliefert. In PostgreSQL schliessen sich mehrere NULL in einem
|
||||||
|
/// eindeutigen Index nicht aus, die Bedingung bleibt also gueltig.
|
||||||
|
slug String?
|
||||||
type String
|
type String
|
||||||
label String?
|
label String?
|
||||||
data Json
|
data Json
|
||||||
@@ -202,6 +210,7 @@ model QrCode {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([groupId])
|
@@index([groupId])
|
||||||
@@index([domainId])
|
@@index([domainId])
|
||||||
|
@@index([mode])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Eine hochgeladene Datei — das PDF hinter einem PDF-Code, das Bild hinter
|
/// Eine hochgeladene Datei — das PDF hinter einem PDF-Code, das Bild hinter
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { getActor } from "@/lib/auth/actor";
|
|||||||
import { requirePermission } from "@/lib/auth/policy";
|
import { requirePermission } from "@/lib/auth/policy";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { shortUrl } from "@/lib/env";
|
import { shortUrl } from "@/lib/env";
|
||||||
|
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";
|
||||||
@@ -49,7 +50,12 @@ export async function GET(
|
|||||||
margin: code.margin,
|
margin: code.margin,
|
||||||
});
|
});
|
||||||
|
|
||||||
const content = shortUrl(code.slug, code.domain.hostname);
|
// Was im Bild steckt, haengt an der Betriebsart: Ein dynamischer Code
|
||||||
|
// traegt die Kurz-URL, ein statischer seinen Inhalt selbst.
|
||||||
|
const content =
|
||||||
|
code.mode === "static" || !code.slug
|
||||||
|
? requireImplementedQrType(code.type).encode(code.data)
|
||||||
|
: 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))
|
||||||
|
|||||||
@@ -96,6 +96,15 @@ export async function PATCH(
|
|||||||
/** Dateien, die nach der Änderung zu diesem Code gehören. */
|
/** Dateien, die nach der Änderung zu diesem Code gehören. */
|
||||||
let neueDateien: string[] | null = null;
|
let neueDateien: string[] | null = null;
|
||||||
|
|
||||||
|
// Der Inhalt eines statischen Codes steckt im gedruckten Bild. Ihn hier zu
|
||||||
|
// ändern würde die Ablage von dem entfernen, was tatsächlich im Umlauf
|
||||||
|
// ist — der Eintrag ist ein Schnappschuss, kein steuerbares Ziel.
|
||||||
|
if (parsed.data.data !== undefined && existing.mode === "static") {
|
||||||
|
return jsonError(
|
||||||
|
"Der Inhalt eines statischen Codes lässt sich nicht ändern — er steckt im gedruckten Code selbst. Änderbar ist nur der Name.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (parsed.data.data !== undefined) {
|
if (parsed.data.data !== undefined) {
|
||||||
// Bewusst nur „ausprogrammiert“, nicht „freigegeben“: ein in der
|
// Bewusst nur „ausprogrammiert“, nicht „freigegeben“: ein in der
|
||||||
// Verwaltung abgeschalteter Typ soll bestehende Codes nicht
|
// Verwaltung abgeschalteter Typ soll bestehende Codes nicht
|
||||||
@@ -127,8 +136,16 @@ export async function PATCH(
|
|||||||
/* ------------------------------------------------------- Kurz-Link */
|
/* ------------------------------------------------------- Kurz-Link */
|
||||||
|
|
||||||
const neuerSlug = parsed.data.slug?.trim();
|
const neuerSlug = parsed.data.slug?.trim();
|
||||||
|
// Ein statischer Code hat keinen Kurz-Link — sein Inhalt steckt in ihm
|
||||||
|
// selbst. Ein Umbenennen waere gegenstandslos.
|
||||||
|
if (existing.mode === "static" && neuerSlug) {
|
||||||
|
return jsonError(
|
||||||
|
"Ein statischer Code hat keinen Kurz-Link. Sein Inhalt steckt im Code selbst.",
|
||||||
|
);
|
||||||
|
}
|
||||||
const umbenannt =
|
const umbenannt =
|
||||||
neuerSlug !== undefined &&
|
neuerSlug !== undefined &&
|
||||||
|
existing.slug !== null &&
|
||||||
normalizeSlug(neuerSlug) !== normalizeSlug(existing.slug);
|
normalizeSlug(neuerSlug) !== normalizeSlug(existing.slug);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -191,7 +208,8 @@ export async function PATCH(
|
|||||||
await tx.qrCodeAlias.create({
|
await tx.qrCodeAlias.create({
|
||||||
data: {
|
data: {
|
||||||
domainId: existing.domainId,
|
domainId: existing.domainId,
|
||||||
slug: existing.slug,
|
// `umbenannt` gilt nur bei vorhandenem Kurz-Link.
|
||||||
|
slug: existing.slug as string,
|
||||||
qrCodeId: existing.id,
|
qrCodeId: existing.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ const createSchema = z.object({
|
|||||||
.trim()
|
.trim()
|
||||||
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
|
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
|
||||||
.optional(),
|
.optional(),
|
||||||
|
/**
|
||||||
|
* Statisch oder dynamisch. Ein statischer Code wird nur abgelegt, damit man
|
||||||
|
* ihn wiederfindet — er bekommt keinen Kurz-Link und zählt keine Scans.
|
||||||
|
*/
|
||||||
|
mode: z.enum(["static", "dynamic"], { error: "Ungültiger Modus." })
|
||||||
|
.default("dynamic"),
|
||||||
/**
|
/**
|
||||||
* Selbst gewählter Kurz-Link. Ohne Angabe wird einer erzeugt.
|
* Selbst gewählter Kurz-Link. Ohne Angabe wird einer erzeugt.
|
||||||
* Geprüft wird er in `describeSlugProblem` — hier nur die grobe Länge.
|
* Geprüft wird er in `describeSlugProblem` — hier nur die grobe Länge.
|
||||||
@@ -101,8 +107,25 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
return jsonError(validated.message);
|
return jsonError(validated.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Erst hier, nach der Validierung: die Probe braucht die geprüften Daten.
|
const istStatisch = parsed.data.mode === "static";
|
||||||
assertDynamicPossible(qrType, validated.data);
|
|
||||||
|
if (istStatisch) {
|
||||||
|
// Ein statischer Code trägt seinen Inhalt selbst — der Typ muss das
|
||||||
|
// können, sonst gäbe es nichts zu kodieren.
|
||||||
|
if (!qrType.supportsStatic) {
|
||||||
|
return jsonError(
|
||||||
|
`Der QR-Typ „${qrType.title}“ funktioniert nur als dynamischer Code.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (parsed.data.slug) {
|
||||||
|
return jsonError(
|
||||||
|
"Ein statischer Code hat keinen Kurz-Link. Sein Inhalt steckt im Code selbst.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Erst hier, nach der Validierung: die Probe braucht die geprüften Daten.
|
||||||
|
assertDynamicPossible(qrType, validated.data);
|
||||||
|
}
|
||||||
|
|
||||||
// Ein selbst gewählter Kurz-Link wird geprüft, bevor irgendetwas entsteht —
|
// Ein selbst gewählter Kurz-Link wird geprüft, bevor irgendetwas entsteht —
|
||||||
// sonst stünde am Ende ein Code mit einer Adresse, die niemand wollte.
|
// sonst stünde am Ende ein Code mit einer Adresse, die niemand wollte.
|
||||||
@@ -117,6 +140,30 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
await assertSlugAvailable(domainId, gewuenscht);
|
await assertSlugAvailable(domainId, gewuenscht);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (istStatisch) {
|
||||||
|
const created = await prisma.qrCode.create({
|
||||||
|
data: {
|
||||||
|
mode: "static",
|
||||||
|
slug: null,
|
||||||
|
type: qrType.id,
|
||||||
|
label: label && label.length > 0 ? label : null,
|
||||||
|
data: toJsonInput(validated.data),
|
||||||
|
groupId,
|
||||||
|
domainId,
|
||||||
|
createdById: actor.kind === "user" ? actor.user.id : null,
|
||||||
|
...design,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
group: { select: { name: true } },
|
||||||
|
domain: { select: { hostname: true } },
|
||||||
|
createdBy: { select: { name: true, email: true } },
|
||||||
|
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await attachUploads(created.id, qrType.uploadIds(validated.data));
|
||||||
|
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
// Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich.
|
// Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich.
|
||||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
return new Response(body, {
|
return new Response(body, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": CONTENT_TYPES[format],
|
"Content-Type": CONTENT_TYPES[format],
|
||||||
"Content-Disposition": `attachment; filename="${downloadFilename(qrType, data, format)}"`,
|
"Content-Disposition": `attachment; filename="${downloadFilename(qrType, data, format, parsed.data.label)}"`,
|
||||||
"Cache-Control": "no-store",
|
"Cache-Control": "no-store",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -91,6 +91,17 @@ export default async function LandingPage({ params }: PageProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gefunden wird ein Code nur ueber seinen Kurz-Link; statische haben keinen
|
||||||
|
// und koennen hier deshalb gar nicht ankommen. Der Zweig ist reine Vorsorge.
|
||||||
|
if (!code.slug) {
|
||||||
|
return (
|
||||||
|
<LandingNotice title="Diese Seite gibt es nicht">
|
||||||
|
Dieser Code führt nicht auf eine Seite. Bitte wende dich an denjenigen,
|
||||||
|
der ihn erstellt hat.
|
||||||
|
</LandingNotice>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Landing
|
<Landing
|
||||||
data={code.data}
|
data={code.data}
|
||||||
|
|||||||
+110
-62
@@ -133,7 +133,7 @@ export function CodesList({
|
|||||||
setEditing(code);
|
setEditing(code);
|
||||||
setEditData(code.data);
|
setEditData(code.data);
|
||||||
setEditLabel(code.label ?? "");
|
setEditLabel(code.label ?? "");
|
||||||
setEditSlug(code.slug);
|
setEditSlug(code.slug ?? "");
|
||||||
setEditAliases(code.aliases);
|
setEditAliases(code.aliases);
|
||||||
setEditError(null);
|
setEditError(null);
|
||||||
};
|
};
|
||||||
@@ -141,7 +141,9 @@ export function CodesList({
|
|||||||
const slugProblem =
|
const slugProblem =
|
||||||
editSlug.trim().length > 0 ? describeSlugProblem(editSlug) : null;
|
editSlug.trim().length > 0 ? describeSlugProblem(editSlug) : null;
|
||||||
const slugGeaendert =
|
const slugGeaendert =
|
||||||
editing !== null && editSlug.trim().toLowerCase() !== editing.slug.toLowerCase();
|
editing !== null &&
|
||||||
|
editing.slug !== null &&
|
||||||
|
editSlug.trim().toLowerCase() !== editing.slug.toLowerCase();
|
||||||
const freigegeben = editing
|
const freigegeben = editing
|
||||||
? editing.aliases.filter((alias) => !editAliases.includes(alias))
|
? editing.aliases.filter((alias) => !editAliases.includes(alias))
|
||||||
: [];
|
: [];
|
||||||
@@ -168,12 +170,18 @@ export function CodesList({
|
|||||||
await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, {
|
await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
json: {
|
json: {
|
||||||
data: editValidation.data,
|
|
||||||
label: editLabel.trim() || null,
|
label: editLabel.trim() || null,
|
||||||
// Nur mitschicken, wenn es überhaupt bearbeitet werden durfte.
|
// Ein statischer Code ist ein Schnappschuss — Inhalt und Kurz-Link
|
||||||
...(maySetSlug
|
// gehören nicht in die Anfrage.
|
||||||
? { slug: editSlug.trim(), aliases: editAliases }
|
...(editing.mode === "static"
|
||||||
: {}),
|
? {}
|
||||||
|
: {
|
||||||
|
data: editValidation.data,
|
||||||
|
// Nur mitschicken, wenn es bearbeitet werden durfte.
|
||||||
|
...(maySetSlug
|
||||||
|
? { slug: editSlug.trim(), aliases: editAliases }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
@@ -209,6 +217,7 @@ export function CodesList({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const copy = async (code: QrCodeDto) => {
|
const copy = async (code: QrCodeDto) => {
|
||||||
|
if (!code.shortUrl) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(code.shortUrl);
|
await navigator.clipboard.writeText(code.shortUrl);
|
||||||
setCopiedId(code.id);
|
setCopiedId(code.id);
|
||||||
@@ -479,9 +488,18 @@ export function CodesList({
|
|||||||
<div className="truncate text-xs text-slate-500">
|
<div className="truncate text-xs text-slate-500">
|
||||||
{/* Nur der Pfad: Der Hostname ist auf dem Handy
|
{/* Nur der Pfad: Der Hostname ist auf dem Handy
|
||||||
gleich für alle Zeilen, der Slug unterscheidet. */}
|
gleich für alle Zeilen, der Slug unterscheidet. */}
|
||||||
<span className="font-mono md:hidden" title={code.shortUrl}>
|
{code.shortUrl ? (
|
||||||
{code.shortUrl.replace(/^https?:\/\/[^/]+/, "")}
|
<span
|
||||||
</span>
|
className="font-mono md:hidden"
|
||||||
|
title={code.shortUrl}
|
||||||
|
>
|
||||||
|
{code.shortUrl.replace(/^https?:\/\/[^/]+/, "")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="md:hidden">
|
||||||
|
{qrTypes[code.type]?.title ?? code.type} · statisch
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="hidden md:inline">
|
<span className="hidden md:inline">
|
||||||
{qrTypes[code.type]?.title ?? code.type}
|
{qrTypes[code.type]?.title ?? code.type}
|
||||||
</span>
|
</span>
|
||||||
@@ -491,28 +509,34 @@ export function CodesList({
|
|||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="hidden px-3 py-2.5 md:table-cell">
|
<td className="hidden px-3 py-2.5 md:table-cell">
|
||||||
<div className="flex items-center gap-1.5">
|
{code.shortUrl ? (
|
||||||
<a
|
<div className="flex items-center gap-1.5">
|
||||||
href={code.shortUrl}
|
<a
|
||||||
target="_blank"
|
href={code.shortUrl}
|
||||||
rel="noreferrer"
|
target="_blank"
|
||||||
className="truncate font-mono text-xs text-accent-text hover:underline"
|
rel="noreferrer"
|
||||||
title={code.shortUrl}
|
className="truncate font-mono text-xs text-accent-text hover:underline"
|
||||||
>
|
title={code.shortUrl}
|
||||||
{code.shortUrl.replace(/^https?:\/\//, "")}
|
>
|
||||||
</a>
|
{code.shortUrl.replace(/^https?:\/\//, "")}
|
||||||
<button
|
</a>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => void copy(code)}
|
type="button"
|
||||||
title="Kurz-URL kopieren"
|
onClick={() => void copy(code)}
|
||||||
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
title="Kurz-URL kopieren"
|
||||||
>
|
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||||
<Icon
|
>
|
||||||
name={copiedId === code.id ? "check" : "copy"}
|
<Icon
|
||||||
className="size-3.5"
|
name={copiedId === code.id ? "check" : "copy"}
|
||||||
/>
|
className="size-3.5"
|
||||||
</button>
|
/>
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Ein statischer Code hat keine Kurz-URL — sein Inhalt
|
||||||
|
// steckt in ihm selbst.
|
||||||
|
<Badge tone="neutral">statisch</Badge>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="hidden px-3 py-2.5 sm:table-cell">
|
<td className="hidden px-3 py-2.5 sm:table-cell">
|
||||||
@@ -527,7 +551,13 @@ export function CodesList({
|
|||||||
{code.target.replace(/^https?:\/\//, "")}
|
{code.target.replace(/^https?:\/\//, "")}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="block truncate text-red-600" title={code.summary}>
|
<span
|
||||||
|
className={cx(
|
||||||
|
"block truncate",
|
||||||
|
code.mode === "static" ? "text-slate-600" : "text-red-600",
|
||||||
|
)}
|
||||||
|
title={code.summary}
|
||||||
|
>
|
||||||
{code.summary}
|
{code.summary}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -542,7 +572,13 @@ export function CodesList({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<td className="px-3 py-2.5 text-right font-semibold text-slate-900 tabular-nums">
|
<td className="px-3 py-2.5 text-right font-semibold text-slate-900 tabular-nums">
|
||||||
{code.scans}
|
{code.mode === "static" ? (
|
||||||
|
<span className="font-normal text-slate-400" title="Statische Codes werden nicht gezählt">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
code.scans
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td
|
<td
|
||||||
@@ -640,30 +676,41 @@ export function CodesList({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl className="mt-3 space-y-2 text-sm">
|
<dl className="mt-3 space-y-2 text-sm">
|
||||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
{code.shortUrl ? (
|
||||||
<dt className="text-slate-500">Kurz-URL:</dt>
|
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||||
<dd className="flex items-center gap-2">
|
<dt className="text-slate-500">Kurz-URL:</dt>
|
||||||
<a
|
<dd className="flex items-center gap-2">
|
||||||
href={code.shortUrl}
|
<a
|
||||||
target="_blank"
|
href={code.shortUrl}
|
||||||
rel="noreferrer"
|
target="_blank"
|
||||||
className="font-mono text-xs break-all text-accent-text hover:underline"
|
rel="noreferrer"
|
||||||
>
|
className="font-mono text-xs break-all text-accent-text hover:underline"
|
||||||
{code.shortUrl}
|
>
|
||||||
</a>
|
{code.shortUrl}
|
||||||
<button
|
</a>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => void copy(code)}
|
type="button"
|
||||||
title="Kurz-URL kopieren"
|
onClick={() => void copy(code)}
|
||||||
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
title="Kurz-URL kopieren"
|
||||||
>
|
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||||
<Icon
|
>
|
||||||
name={copiedId === code.id ? "check" : "copy"}
|
<Icon
|
||||||
className="size-4"
|
name={copiedId === code.id ? "check" : "copy"}
|
||||||
/>
|
className="size-4"
|
||||||
</button>
|
/>
|
||||||
</dd>
|
</button>
|
||||||
</div>
|
</dd>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Statisch: Der Inhalt steckt im Code selbst, es gibt keine
|
||||||
|
// Kurz-URL und nichts, was sich nachträglich umbiegen liesse.
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||||
|
<dt className="text-slate-500">Art:</dt>
|
||||||
|
<dd className="text-slate-800">
|
||||||
|
statisch — Inhalt steckt im Code, nicht änderbar
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||||
<dt className="text-slate-500">Ziel:</dt>
|
<dt className="text-slate-500">Ziel:</dt>
|
||||||
@@ -772,11 +819,12 @@ export function CodesList({
|
|||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-slate-600">
|
<p className="text-sm text-slate-600">
|
||||||
Das Ziel lässt sich jederzeit ändern — der gedruckte QR-Code bleibt
|
{editing.mode === "static"
|
||||||
gültig, solange der Kurz-Link derselbe ist.
|
? "Ein statischer Code trägt seinen Inhalt selbst — änderbar ist deshalb nur der Name, unter dem du ihn hier wiederfindest."
|
||||||
|
: "Das Ziel lässt sich jederzeit ändern — der gedruckte QR-Code bleibt gültig, solange der Kurz-Link derselbe ist."}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{EditForm ? (
|
{editing.mode === "static" ? null : EditForm ? (
|
||||||
<EditForm
|
<EditForm
|
||||||
value={editData}
|
value={editData}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
@@ -792,7 +840,7 @@ export function CodesList({
|
|||||||
</Notice>
|
</Notice>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{maySetSlug ? (
|
{maySetSlug && editing.mode !== "static" ? (
|
||||||
<>
|
<>
|
||||||
<Field
|
<Field
|
||||||
label="Kurz-Link"
|
label="Kurz-Link"
|
||||||
|
|||||||
@@ -84,11 +84,13 @@ export function QrGenerator({
|
|||||||
const dataError = !validation.ok && touched ? validation.message : null;
|
const dataError = !validation.ok && touched ? validation.message : null;
|
||||||
|
|
||||||
const mode = isDynamic ? "dynamic" : "static";
|
const mode = isDynamic ? "dynamic" : "static";
|
||||||
const slug = isDynamic ? saved?.slug : undefined;
|
const slug = (isDynamic ? saved?.slug : undefined) ?? undefined;
|
||||||
|
|
||||||
const requestBody = useMemo(
|
const requestBody = useMemo(
|
||||||
() => ({ type: typeId, data, design, mode, slug }),
|
// Der Name geht mit: Der Server bildet daraus den Dateinamen beim
|
||||||
[typeId, data, design, mode, slug],
|
// Herunterladen. Auf die Vorschau hat er keinen Einfluss.
|
||||||
|
() => ({ type: typeId, data, design, mode, slug, label: label.trim() || undefined }),
|
||||||
|
[typeId, data, design, mode, slug, label],
|
||||||
);
|
);
|
||||||
|
|
||||||
const ready = validation.ok && designValid;
|
const ready = validation.ok && designValid;
|
||||||
@@ -194,8 +196,10 @@ export function QrGenerator({
|
|||||||
type: typeId,
|
type: typeId,
|
||||||
data: validation.data,
|
data: validation.data,
|
||||||
design,
|
design,
|
||||||
|
mode,
|
||||||
label: label.trim() || undefined,
|
label: label.trim() || undefined,
|
||||||
slug: wunschSlug.trim() || undefined,
|
// Ein statischer Code hat keinen Kurz-Link.
|
||||||
|
slug: isDynamic ? wunschSlug.trim() || undefined : undefined,
|
||||||
};
|
};
|
||||||
const result = saved
|
const result = saved
|
||||||
? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, {
|
? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, {
|
||||||
@@ -216,10 +220,22 @@ export function QrGenerator({
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [validation, designValid, slugProblem, typeId, design, label, wunschSlug, saved]);
|
}, [validation, designValid, slugProblem, typeId, design, mode, isDynamic, label, wunschSlug, saved]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wechselt die Betriebsart und vergisst dabei einen zuvor gespeicherten
|
||||||
|
* Code. Ein abgelegter statischer Eintrag ist etwas anderes als ein
|
||||||
|
* gespeicherter dynamischer — beides gleichzeitig festzuhalten führte
|
||||||
|
* dazu, dass die Oberfläche den falschen Zustand zeigt.
|
||||||
|
*/
|
||||||
|
const wechsleModus = useCallback((dynamisch: boolean) => {
|
||||||
|
setIsDynamic(dynamisch);
|
||||||
|
setSaved(null);
|
||||||
|
setSaveError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleCopy = useCallback(async () => {
|
const handleCopy = useCallback(async () => {
|
||||||
if (!saved) return;
|
if (!saved?.shortUrl) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(saved.shortUrl);
|
await navigator.clipboard.writeText(saved.shortUrl);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
@@ -302,7 +318,7 @@ export function QrGenerator({
|
|||||||
data-mode="static"
|
data-mode="static"
|
||||||
aria-pressed={!isDynamic}
|
aria-pressed={!isDynamic}
|
||||||
disabled={!qrType.supportsStatic}
|
disabled={!qrType.supportsStatic}
|
||||||
onClick={() => setIsDynamic(false)}
|
onClick={() => wechsleModus(false)}
|
||||||
className={cx(
|
className={cx(
|
||||||
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
!isDynamic
|
!isDynamic
|
||||||
@@ -326,7 +342,7 @@ export function QrGenerator({
|
|||||||
data-mode="dynamic"
|
data-mode="dynamic"
|
||||||
aria-pressed={isDynamic}
|
aria-pressed={isDynamic}
|
||||||
disabled={!qrType.supportsDynamic}
|
disabled={!qrType.supportsDynamic}
|
||||||
onClick={() => setIsDynamic(true)}
|
onClick={() => wechsleModus(true)}
|
||||||
className={cx(
|
className={cx(
|
||||||
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
isDynamic
|
isDynamic
|
||||||
@@ -347,12 +363,15 @@ export function QrGenerator({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isDynamic ? (
|
<div className="mt-5 space-y-4 border-t border-slate-200 pt-5">
|
||||||
<div className="mt-5 space-y-4 border-t border-slate-200 pt-5">
|
|
||||||
<Field
|
<Field
|
||||||
label="Name (optional)"
|
label="Name (optional)"
|
||||||
htmlFor="code-label"
|
htmlFor="code-label"
|
||||||
hint="Hilft dir, den Code später in „Meine Codes“ wiederzufinden."
|
hint={
|
||||||
|
isDynamic
|
||||||
|
? "Hilft dir, den Code später in „Meine Codes“ wiederzufinden."
|
||||||
|
: "Bestimmt den Dateinamen beim Herunterladen — und den Eintrag, falls du ihn ablegst."
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
id="code-label"
|
id="code-label"
|
||||||
@@ -363,9 +382,7 @@ export function QrGenerator({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
{/* Nur beim Anlegen: Danach steht der Kurz-Link im
|
{!isDynamic || saved || !maySetSlug ? null : (
|
||||||
Bearbeiten-Dialog, wo auch die früheren Adressen liegen. */}
|
|
||||||
{saved || !maySetSlug ? null : (
|
|
||||||
<Field
|
<Field
|
||||||
label="Kurz-Link (optional)"
|
label="Kurz-Link (optional)"
|
||||||
htmlFor="code-slug"
|
htmlFor="code-slug"
|
||||||
@@ -384,7 +401,38 @@ export function QrGenerator({
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{saved ? (
|
{!isDynamic ? (
|
||||||
|
/* Statisch: Der Code ist ohne Speichern fertig. Ablegen ist
|
||||||
|
ein Angebot, kein Zwang — es geht nur ums Wiederfinden.
|
||||||
|
Nach einer Inhaltsänderung ist die Ablage ein Schnappschuss
|
||||||
|
von vorher; dann wird sie wieder angeboten. */
|
||||||
|
saved && !hasUnsavedChanges ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Notice tone="success">
|
||||||
|
<span className="font-medium">Abgelegt.</span> Du findest
|
||||||
|
den Code jetzt unter „Meine Codes“ und kannst ihn dort
|
||||||
|
erneut herunterladen. Der Code selbst ändert sich dadurch
|
||||||
|
nicht — sein Inhalt steckt in ihm.
|
||||||
|
</Notice>
|
||||||
|
<Link
|
||||||
|
href="/meine-codes"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-accent-text hover:text-accent-soft-fg"
|
||||||
|
>
|
||||||
|
Zu „Meine Codes“
|
||||||
|
<Icon name="external" className="size-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !ready}
|
||||||
|
>
|
||||||
|
{saving ? "Legt ab …" : "In „Meine Codes“ ablegen"}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
) : saved ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Notice tone="success">
|
<Notice tone="success">
|
||||||
<span className="font-medium">Code gespeichert.</span> Der
|
<span className="font-medium">Code gespeichert.</span> Der
|
||||||
@@ -453,8 +501,7 @@ export function QrGenerator({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{saveError ? <Notice tone="error">{saveError}</Notice> : null}
|
{saveError ? <Notice tone="error">{saveError}</Notice> : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</Card>
|
</Card>
|
||||||
</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}`;
|
||||||
|
}
|
||||||
+13
-4
@@ -38,16 +38,24 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
|
|||||||
const data = asRecord(code.data);
|
const data = asRecord(code.data);
|
||||||
const qrType = getQrType(code.type);
|
const qrType = getQrType(code.type);
|
||||||
|
|
||||||
|
const istStatisch = code.mode === "static";
|
||||||
|
|
||||||
let target = "";
|
let target = "";
|
||||||
let summary = "";
|
let summary = "";
|
||||||
if (qrType) {
|
if (qrType) {
|
||||||
try {
|
try {
|
||||||
|
// Ein statischer Code führt nirgendwohin — sein Inhalt steckt in ihm
|
||||||
|
// selbst. Als „Ziel“ bleibt deshalb nichts übrig; die Zusammenfassung
|
||||||
|
// sagt trotzdem, was drinsteht.
|
||||||
// Typen mit eigener Landingpage führen auf eine Seite dieser Anwendung.
|
// Typen mit eigener Landingpage führen auf eine Seite dieser Anwendung.
|
||||||
// Die ist als „Ziel“ das Ehrlichste — und obendrein anklickbar, um sie
|
// Die ist als „Ziel“ das Ehrlichste — und obendrein anklickbar, um sie
|
||||||
// sich anzusehen.
|
// sich anzusehen.
|
||||||
target = qrType.hasLandingPage
|
target =
|
||||||
? landingUrl(code.slug, code.domain?.hostname)
|
istStatisch || !code.slug
|
||||||
: qrType.redirectTarget(data);
|
? ""
|
||||||
|
: qrType.hasLandingPage
|
||||||
|
? landingUrl(code.slug, code.domain?.hostname)
|
||||||
|
: qrType.redirectTarget(data);
|
||||||
summary = qrType.summary(data);
|
summary = qrType.summary(data);
|
||||||
} catch {
|
} catch {
|
||||||
// Datensatz passt nicht mehr zum Typ-Schema (z. B. nach einem
|
// Datensatz passt nicht mehr zum Typ-Schema (z. B. nach einem
|
||||||
@@ -60,12 +68,13 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: code.id,
|
id: code.id,
|
||||||
|
mode: istStatisch ? "static" : "dynamic",
|
||||||
slug: code.slug,
|
slug: code.slug,
|
||||||
aliases: (code.aliases ?? []).map((alias) => alias.slug),
|
aliases: (code.aliases ?? []).map((alias) => alias.slug),
|
||||||
type: code.type as QrTypeId,
|
type: code.type as QrTypeId,
|
||||||
label: code.label,
|
label: code.label,
|
||||||
data,
|
data,
|
||||||
shortUrl: shortUrl(code.slug, code.domain?.hostname),
|
shortUrl: code.slug ? shortUrl(code.slug, code.domain?.hostname) : null,
|
||||||
target,
|
target,
|
||||||
summary,
|
summary,
|
||||||
design: {
|
design: {
|
||||||
|
|||||||
+15
-5
@@ -1,21 +1,31 @@
|
|||||||
import type { EcLevel } from "@/lib/qr/design";
|
import type { EcLevel } 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. */
|
||||||
|
export type QrCodeMode = "static" | "dynamic";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Darstellung eines dynamischen Codes an der API-Grenze. Bewusst frei von
|
* Darstellung eines gespeicherten Codes an der API-Grenze. Bewusst frei von
|
||||||
* Server-Abhängigkeiten, damit Client-Komponenten den Typ verwenden können.
|
* Server-Abhängigkeiten, damit Client-Komponenten den Typ verwenden können.
|
||||||
*/
|
*/
|
||||||
export type QrCodeDto = {
|
export type QrCodeDto = {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
/**
|
||||||
|
* Ein statischer Code trägt seinen Inhalt selbst. Er ist nur abgelegt, damit
|
||||||
|
* man ihn wiederfindet und erneut herunterladen kann — Kurz-Link, Ziel und
|
||||||
|
* Scan-Zähler bleiben deshalb leer.
|
||||||
|
*/
|
||||||
|
mode: QrCodeMode;
|
||||||
|
/** Kurz-Link. Leer bei statischen Codes. */
|
||||||
|
slug: string | null;
|
||||||
/** Frühere Kurz-Links, die weiterhin auf diesen Code führen. */
|
/** Frühere Kurz-Links, die weiterhin auf diesen Code führen. */
|
||||||
aliases: string[];
|
aliases: string[];
|
||||||
type: QrTypeId;
|
type: QrTypeId;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
/** Die Kurz-URL, die im QR-Code steckt. */
|
/** Die Kurz-URL, die im QR-Code steckt. Leer bei statischen Codes. */
|
||||||
shortUrl: string;
|
shortUrl: string | null;
|
||||||
/** Aktuelles Weiterleitungsziel. */
|
/** Aktuelles Weiterleitungsziel. Leer bei statischen Codes. */
|
||||||
target: string;
|
target: string;
|
||||||
/** Lesbare Kurzfassung des Ziels. */
|
/** Lesbare Kurzfassung des Ziels. */
|
||||||
summary: string;
|
summary: string;
|
||||||
|
|||||||
+28
-10
@@ -36,6 +36,8 @@ export const qrRequestSchema = z.object({
|
|||||||
slug: z.string().trim().optional(),
|
slug: z.string().trim().optional(),
|
||||||
/** Domain, unter der die Kurz-URL entsteht. */
|
/** Domain, unter der die Kurz-URL entsteht. */
|
||||||
domainId: z.string().trim().optional(),
|
domainId: z.string().trim().optional(),
|
||||||
|
/** Name des Codes — bestimmt den Dateinamen beim Herunterladen. */
|
||||||
|
label: z.string().trim().max(120).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type QrRequest = z.infer<typeof qrRequestSchema>;
|
export type QrRequest = z.infer<typeof qrRequestSchema>;
|
||||||
@@ -137,25 +139,41 @@ export async function resolveQrRequest(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dateiname für den Download, abgeleitet aus dem Inhalt des Codes. */
|
/** Macht aus beliebigem Text einen brauchbaren Dateinamen-Bestandteil. */
|
||||||
|
function alsDateiname(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll("ä", "ae")
|
||||||
|
.replaceAll("ö", "oe")
|
||||||
|
.replaceAll("ü", "ue")
|
||||||
|
.replaceAll("ß", "ss")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dateiname für den Download.
|
||||||
|
*
|
||||||
|
* Wenn ein Name vergeben wurde, gewinnt er — genau dafür ist er da: damit man
|
||||||
|
* die Datei später wiederfindet. Sonst wie bisher aus dem Inhalt abgeleitet.
|
||||||
|
*/
|
||||||
export function downloadFilename(
|
export function downloadFilename(
|
||||||
qrType: QrTypeModule,
|
qrType: QrTypeModule,
|
||||||
data: unknown,
|
data: unknown,
|
||||||
extension: string,
|
extension: string,
|
||||||
|
label?: string | null,
|
||||||
): string {
|
): string {
|
||||||
|
const ausName = label ? alsDateiname(label) : "";
|
||||||
|
if (ausName) return `qr-${ausName}.${extension}`;
|
||||||
|
|
||||||
let base = "qr-code";
|
let base = "qr-code";
|
||||||
try {
|
try {
|
||||||
const summary = qrType
|
const summary = qrType
|
||||||
.summary(data)
|
.summary(data)
|
||||||
.toLowerCase()
|
.toLowerCase();
|
||||||
.replaceAll("ä", "ae")
|
const gesaeubert = alsDateiname(summary);
|
||||||
.replaceAll("ö", "oe")
|
if (gesaeubert) base = `qr-${gesaeubert}`;
|
||||||
.replaceAll("ü", "ue")
|
|
||||||
.replaceAll("ß", "ss")
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/^-+|-+$/g, "")
|
|
||||||
.slice(0, 60);
|
|
||||||
if (summary) base = `qr-${summary}`;
|
|
||||||
} catch {
|
} catch {
|
||||||
// Zusammenfassung ist optional — im Zweifel bleibt der Standardname.
|
// Zusammenfassung ist optional — im Zweifel bleibt der Standardname.
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user