Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b96e9ddbe5 | ||
|
|
586e1f042f | ||
|
|
7f67a35218 | ||
|
|
eeaf3820b7 | ||
|
|
46e7eb0b59 | ||
|
|
c38b27ca85 | ||
|
|
fcf41f56fe | ||
|
|
b828f38ca5 | ||
|
|
4046b72626 | ||
|
|
d0057f038c |
@@ -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
|
||||||
@@ -27,6 +28,14 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
|
|||||||
- **Mehrere Domains** für Kurz-URLs, den Gruppen zugeteilt.
|
- **Mehrere Domains** für Kurz-URLs, den Gruppen zugeteilt.
|
||||||
- **Single Sign-on** über Authentik oder einen anderen OIDC-Anbieter.
|
- **Single Sign-on** über Authentik oder einen anderen OIDC-Anbieter.
|
||||||
- **Typen abschaltbar:** Was ihr nicht braucht, erscheint gar nicht erst.
|
- **Typen abschaltbar:** Was ihr nicht braucht, erscheint gar nicht erst.
|
||||||
|
- **Eigenes Erscheinungsbild:** Name, Logo, Favicon und Akzentfarbe im
|
||||||
|
Admin-Bereich einstellbar.
|
||||||
|
- **Hell und dunkel:** folgt dem Betriebssystem, mit Umschalter für eine
|
||||||
|
eigene Wahl.
|
||||||
|
- **Sprechende Kurz-Links:** `qr.firma.de/r/sommeraktion` statt Zufallszeichen,
|
||||||
|
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`.
|
||||||
|
|
||||||
@@ -41,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
|
||||||
|
|
||||||
@@ -120,9 +130,25 @@ BASE_URL=http://192.168.1.50:3000
|
|||||||
> **Warum `BASE_URL` so wichtig ist:** Ein dynamischer QR-Code enthält nicht
|
> **Warum `BASE_URL` so wichtig ist:** Ein dynamischer QR-Code enthält nicht
|
||||||
> das eigentliche Ziel, sondern die Kurz-URL `{BASE_URL}/r/{slug}`. Steht dort
|
> das eigentliche Ziel, sondern die Kurz-URL `{BASE_URL}/r/{slug}`. Steht dort
|
||||||
> `localhost`, funktioniert der Code **nur auf dem Server selbst** — ein Handy
|
> `localhost`, funktioniert der Code **nur auf dem Server selbst** — ein Handy
|
||||||
> läuft ins Leere. Trage deshalb die LAN-Adresse des Servers oder die
|
> läuft ins Leere. Bereits gedruckte Codes lassen sich später nicht mehr
|
||||||
> öffentliche Domain ein. Bereits gedruckte Codes lassen sich später nicht
|
> umbiegen, also am besten vor dem ersten produktiven Code festlegen.
|
||||||
> mehr umbiegen, also am besten vor dem ersten produktiven Code festlegen.
|
>
|
||||||
|
> Die Frage dahinter lautet: **Wer soll die Codes scannen können?**
|
||||||
|
>
|
||||||
|
> | Wer scannt | Was in `BASE_URL` gehört |
|
||||||
|
> | --- | --- |
|
||||||
|
> | nur Leute im eigenen Netz oder per VPN | die interne Adresse, z. B. `http://192.168.1.50:3000` |
|
||||||
|
> | auch Kundschaft, Besucher, Aushänge im Laden | eine **öffentlich auflösbare** Domain, z. B. `https://qr.example.com` |
|
||||||
|
>
|
||||||
|
> Eine Domain, die nur im internen DNS steht, verhält sich dabei wie eine
|
||||||
|
> LAN-Adresse: Ohne VPN kommt niemand darauf. Ein gedruckter Code führt dann
|
||||||
|
> für alle anderen ins Leere — mit „Server nicht gefunden“, ohne jeden
|
||||||
|
> Hinweis darauf, woran es liegt.
|
||||||
|
>
|
||||||
|
> Und: `BASE_URL` muss **dieselbe** Adresse sein, über die ihr die Oberfläche
|
||||||
|
> aufruft — nicht bloß irgendeine erreichbare. Sonst scheitert die Anmeldung
|
||||||
|
> über einen externen Anbieter, siehe
|
||||||
|
> [docs/authentik.md](docs/authentik.md#base_url-und-die-adresse-im-browser-müssen-dieselbe-sein).
|
||||||
|
|
||||||
Der Hostname `db` im `DATABASE_URL` bleibt unverändert — das ist der
|
Der Hostname `db` im `DATABASE_URL` bleibt unverändert — das ist der
|
||||||
Servicename aus der `docker-compose.yml`, nicht dein Server.
|
Servicename aus der `docker-compose.yml`, nicht dein Server.
|
||||||
@@ -230,7 +256,7 @@ BASE_URL=https://qr.deine-domain.de
|
|||||||
| Scheme | `http` |
|
| Scheme | `http` |
|
||||||
| Forward Hostname / IP | IP des Docker-Hosts, z. B. `192.168.1.50` |
|
| Forward Hostname / IP | IP des Docker-Hosts, z. B. `192.168.1.50` |
|
||||||
| Forward Port | `3000` |
|
| Forward Port | `3000` |
|
||||||
| Websockets Support | an |
|
| Websockets Support | nicht nötig — die Anwendung benutzt keine; angeschaltet schadet es aber nicht |
|
||||||
| Block Common Exploits | an |
|
| Block Common Exploits | an |
|
||||||
| SSL | Zertifikat anfordern, *Force SSL* aktivieren |
|
| SSL | Zertifikat anfordern, *Force SSL* aktivieren |
|
||||||
|
|
||||||
@@ -245,9 +271,76 @@ Port. Danach:
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
> `BASE_URL` muss zur Adresse passen, unter der Besucher die Anwendung
|
> `BASE_URL` muss **dieselbe** Adresse sein, unter der ihr die Anwendung
|
||||||
> aufrufen. Steht dort weiterhin `http://192.168.1.50:3000`, zeigen neue
|
> aufruft — nicht bloß irgendeine erreichbare. Steht dort weiterhin
|
||||||
> dynamische Codes ins interne Netz statt auf die Domain.
|
> `http://192.168.1.50:3000`, zeigen neue dynamische Codes ins interne Netz
|
||||||
|
> statt auf die Domain, und die Anmeldung über einen externen Anbieter
|
||||||
|
> scheitert (siehe unten).
|
||||||
|
|
||||||
|
### Was der Proxy weiterreichen muss
|
||||||
|
|
||||||
|
Die Anwendung wertet drei Kopfzeilen aus, die erst der Proxy setzt. Der Nginx
|
||||||
|
Proxy Manager schickt alle drei von sich aus mit — bei anderen Proxys muss man
|
||||||
|
sie eintragen.
|
||||||
|
|
||||||
|
| Kopfzeile | Wofür sie gebraucht wird | Fehlt sie, passiert das |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `X-Forwarded-Proto` | erkennt, dass die Verbindung über HTTPS läuft | Der Sitzungs-Cookie wird ohne `Secure` gesetzt und ginge auch über eine unverschlüsselte Verbindung mit |
|
||||||
|
| `X-Forwarded-Host` | die aufgerufene Domain — für mehrere Domains und für die Anmeldung | Kurz-URLs unter Zweitdomains werden nicht zugeordnet |
|
||||||
|
| `X-Forwarded-For` | Absender für die Bremse gegen Durchprobieren | Alle Aufrufe zählen als derselbe Absender |
|
||||||
|
|
||||||
|
Für ein reines nginx (ohne NPM) heißt das:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
```
|
||||||
|
|
||||||
|
Bei **Traefik** und **Caddy** ist nichts zu tun — beide setzen die Kopfzeilen
|
||||||
|
von sich aus.
|
||||||
|
|
||||||
|
### Größe der Uploads
|
||||||
|
|
||||||
|
PDFs und Bilder gehen durch den Proxy. nginx bricht Anfragen oberhalb von
|
||||||
|
`client_max_body_size` mit **413** ab, und zwar bevor die Anwendung sie zu
|
||||||
|
sehen bekommt — die Fehlermeldung kommt dann vom Proxy und nicht aus der
|
||||||
|
Oberfläche.
|
||||||
|
|
||||||
|
Der Wert muss also mindestens so groß sein wie `MAX_UPLOAD_MB` (Voreinstellung
|
||||||
|
10 MB), besser etwas darüber. Im Nginx Proxy Manager steht das unter
|
||||||
|
**Advanced → Custom Nginx Configuration**:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
client_max_body_size 20m;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nachprüfen, ob es stimmt
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Kommt die Anwendung überhaupt an?
|
||||||
|
curl -I https://qr.deine-domain.de/
|
||||||
|
|
||||||
|
# Kennt der Container dieselbe Adresse wie der Browser?
|
||||||
|
docker compose exec app env | grep BASE_URL
|
||||||
|
```
|
||||||
|
|
||||||
|
Beides muss zusammenpassen. Ein häufiger Fall: Der Proxy läuft, die Domain
|
||||||
|
öffnet sich, aber `BASE_URL` steht noch auf der internen IP aus der
|
||||||
|
Einrichtung. Dann ist die Oberfläche zwar erreichbar, aber
|
||||||
|
|
||||||
|
- jeder neue dynamische Code zeigt auf die interne Adresse, und
|
||||||
|
- die Anmeldung über einen externen Anbieter endet mit einer Fehlermeldung,
|
||||||
|
weil der Anbieter den Browser auf den anderen Host zurückschickt — siehe
|
||||||
|
[docs/authentik.md](docs/authentik.md#base_url-und-die-adresse-im-browser-müssen-dieselbe-sein).
|
||||||
|
|
||||||
|
### Erreichbar für wen?
|
||||||
|
|
||||||
|
Der Proxy entscheidet mit, wer die Codes scannen kann. Steht die Domain nur im
|
||||||
|
internen DNS oder ist sie nur über VPN erreichbar, funktionieren gedruckte
|
||||||
|
Codes **auch nur dort**. Für Aushänge, Flyer oder Verpackungen muss die Domain
|
||||||
|
öffentlich auflösbar und der Proxy von außen erreichbar sein.
|
||||||
|
|
||||||
### Mehrere Domains
|
### Mehrere Domains
|
||||||
|
|
||||||
|
|||||||
+75
-6
@@ -42,7 +42,7 @@ Adressen.
|
|||||||
| Client type | **Confidential** |
|
| Client type | **Confidential** |
|
||||||
| Client ID | notieren — kommt gleich in die `.env` |
|
| Client ID | notieren — kommt gleich in die `.env` |
|
||||||
| Client Secret | notieren — ebenfalls |
|
| Client Secret | notieren — ebenfalls |
|
||||||
| Redirect URIs | `https://qr.example.com/api/auth/oidc/callback` |
|
| Redirect URIs | **deine** `BASE_URL` + `/api/auth/oidc/callback` — siehe unten |
|
||||||
| Signing Key | ein vorhandenes Zertifikat auswählen |
|
| Signing Key | ein vorhandenes Zertifikat auswählen |
|
||||||
| Subject mode | `Based on the User's hashed ID` (Voreinstellung) |
|
| Subject mode | `Based on the User's hashed ID` (Voreinstellung) |
|
||||||
|
|
||||||
@@ -50,9 +50,77 @@ Zu den **Scopes**: `openid`, `email` und `profile` müssen ausgewählt sein.
|
|||||||
Ohne `email` liefert Authentik keine Adresse, und die Anwendung lehnt die
|
Ohne `email` liefert Authentik keine Adresse, und die Anwendung lehnt die
|
||||||
Anmeldung mit einer entsprechenden Meldung ab.
|
Anmeldung mit einer entsprechenden Meldung ab.
|
||||||
|
|
||||||
> **Redirect URI genau so eintragen.** Ein fehlendes `https://`, ein Schrägstrich
|
### Die Redirect URI
|
||||||
> zu viel oder ein anderer Hostname führt zu `redirect_uri mismatch` —
|
|
||||||
> Authentik bricht dann ab, bevor die Anwendung überhaupt etwas mitbekommt.
|
Das ist der häufigste Stolperstein. Der Wert ist **kein Beispiel zum
|
||||||
|
Abschreiben**, sondern ergibt sich aus deiner `BASE_URL`:
|
||||||
|
|
||||||
|
```
|
||||||
|
{BASE_URL}/api/auth/oidc/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
Was dein Container als `BASE_URL` kennt, zeigt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec app env | grep BASE_URL
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein paar Beispiele:
|
||||||
|
|
||||||
|
| `BASE_URL` | Redirect URI |
|
||||||
|
| --- | --- |
|
||||||
|
| `https://qr.example.com` | `https://qr.example.com/api/auth/oidc/callback` |
|
||||||
|
| `http://10.10.10.150:3005` | `http://10.10.10.150:3005/api/auth/oidc/callback` |
|
||||||
|
| `http://192.168.1.50:3000` | `http://192.168.1.50:3000/api/auth/oidc/callback` |
|
||||||
|
|
||||||
|
Zu beachten:
|
||||||
|
|
||||||
|
- **Kein Schrägstrich am Ende.**
|
||||||
|
- **Schema und Port gehören dazu** — `http` ist nicht `https`, und `:3005`
|
||||||
|
darf nicht fehlen.
|
||||||
|
- Neben dem Feld steht in neueren Authentik-Fassungen eine Auswahl **Strict**
|
||||||
|
oder **Regex**. Für eine feste Adresse muss dort **Strict** stehen. Bei
|
||||||
|
`Regex` wäre jeder Punkt einer IP-Adresse ein Platzhalter und müsste
|
||||||
|
maskiert werden.
|
||||||
|
- Bleibt das Feld leer, lässt Authentik **gar nichts** durch.
|
||||||
|
|
||||||
|
> **Wenn es trotzdem klemmt:** Authentik zeigt den Fehler „Redirect URI Error“
|
||||||
|
> noch auf seiner eigenen Seite an — die Anwendung bekommt davon nichts mit.
|
||||||
|
> Welche Adresse tatsächlich ankam, steht zum Vergleich
|
||||||
|
> 1. in der Adresszeile des Browsers, im Parameter `redirect_uri`, und
|
||||||
|
> 2. in Authentik unter **Events → Logs** im Eintrag `redirect_uri_error`.
|
||||||
|
>
|
||||||
|
> Weicht die ankommende Adresse von deiner `BASE_URL` ab, ist nicht Authentik
|
||||||
|
> falsch eingestellt, sondern die `BASE_URL` — und die gehört korrigiert,
|
||||||
|
> bevor der erste dynamische Code entsteht: Sie steckt in jedem davon.
|
||||||
|
|
||||||
|
### `BASE_URL` und die Adresse im Browser müssen dieselbe sein
|
||||||
|
|
||||||
|
Nicht nur „irgendeine erreichbare Adresse“ — **dieselbe**, über die ihr die
|
||||||
|
Anwendung tatsächlich aufruft.
|
||||||
|
|
||||||
|
Der Grund liegt im Ablauf: Beim Klick auf den Anmeldeknopf legt die Anwendung
|
||||||
|
einen Cookie mit dem laufenden Vorgang ab — auf dem Host, über den der Browser
|
||||||
|
gerade da ist. Die Rückkehradresse für Authentik kommt dagegen aus `BASE_URL`.
|
||||||
|
Zeigen die beiden auf verschiedene Hosts, schickt Authentik den Browser auf den
|
||||||
|
anderen Host, der Cookie bleibt beim ersten zurück, und die Anmeldung endet mit
|
||||||
|
**„Der Anmeldevorgang ist abgelaufen“**. Erkennbar ist es daran, dass in der
|
||||||
|
Adresszeile plötzlich eine andere Adresse steht als die, mit der man begonnen
|
||||||
|
hat.
|
||||||
|
|
||||||
|
Seit Version vom 17.08.2026 fängt die Anwendung das ab und nennt beide
|
||||||
|
Adressen, statt den Vorgang bis zum Ende laufen zu lassen.
|
||||||
|
|
||||||
|
Ein häufiges Muster: Die Anwendung läuft hinter einem Reverse Proxy unter
|
||||||
|
`https://qr.example.com`, in der `.env` steht aber noch die interne Adresse
|
||||||
|
`http://10.0.0.5:3005` aus der Einrichtung. Dann gehören **drei** Werte in
|
||||||
|
Übereinstimmung gebracht:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| Aufruf im Browser | `https://qr.example.com` |
|
||||||
|
| `BASE_URL` in der `.env` | `https://qr.example.com` |
|
||||||
|
| Redirect URI in Authentik | `https://qr.example.com/api/auth/oidc/callback` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -216,10 +284,11 @@ Danach verschwindet das Passwortfeld von der Anmeldeseite.
|
|||||||
| Der Knopf erscheint nicht | `AUTH_ENABLED` oder `OIDC_ENABLED` steht nicht auf `true`. Prüfen mit `docker compose exec app env \| grep OIDC`. Steht dort nichts, fehlt die Variable im `environment:`-Block der `docker-compose.yml` — die `.env` allein reicht nicht. |
|
| Der Knopf erscheint nicht | `AUTH_ENABLED` oder `OIDC_ENABLED` steht nicht auf `true`. Prüfen mit `docker compose exec app env \| grep OIDC`. Steht dort nichts, fehlt die Variable im `environment:`-Block der `docker-compose.yml` — die `.env` allein reicht nicht. |
|
||||||
| „Die Anmeldung über den externen Anbieter ist unvollständig eingerichtet“ | Es fehlt `OIDC_ISSUER`, `OIDC_CLIENT_ID` oder `OIDC_CLIENT_SECRET`. Die Meldung nennt, welche. |
|
| „Die Anmeldung über den externen Anbieter ist unvollständig eingerichtet“ | Es fehlt `OIDC_ISSUER`, `OIDC_CLIENT_ID` oder `OIDC_CLIENT_SECRET`. Die Meldung nennt, welche. |
|
||||||
| „Der Anmelde-Anbieter ist nicht erreichbar“ | Der Container kommt nicht an Authentik. Testen mit `docker compose exec app wget -qO- <issuer>/.well-known/openid-configuration`. Häufig: interner DNS-Name statt öffentlicher Adresse. |
|
| „Der Anmelde-Anbieter ist nicht erreichbar“ | Der Container kommt nicht an Authentik. Testen mit `docker compose exec app wget -qO- <issuer>/.well-known/openid-configuration`. Häufig: interner DNS-Name statt öffentlicher Adresse. |
|
||||||
| Authentik meldet `redirect_uri mismatch` | Die Redirect URI im Provider stimmt nicht exakt mit `{BASE_URL}/api/auth/oidc/callback` überein. Auf Schema, Hostname und Schrägstriche achten. |
|
| Authentik zeigt „Redirect URI Error“ | Die Redirect URI im Provider stimmt nicht exakt mit `{BASE_URL}/api/auth/oidc/callback` überein — siehe [Die Redirect URI](#die-redirect-uri). Auf Schema, Port, Schrägstrich am Ende und die Auswahl Strict/Regex achten. |
|
||||||
| „Der Anbieter hat keine E-Mail-Adresse geliefert“ | Der Scope `email` fehlt im Provider, oder der Benutzer hat in Authentik keine Adresse hinterlegt. |
|
| „Der Anbieter hat keine E-Mail-Adresse geliefert“ | Der Scope `email` fehlt im Provider, oder der Benutzer hat in Authentik keine Adresse hinterlegt. |
|
||||||
| „Der Anbieter hat die Anmeldung abgelehnt“ | Der Benutzer ist der Application in Authentik nicht zugeordnet — Policy/Group Binding prüfen. |
|
| „Der Anbieter hat die Anmeldung abgelehnt“ | Der Benutzer ist der Application in Authentik nicht zugeordnet — Policy/Group Binding prüfen. |
|
||||||
| „Der Anmeldevorgang ist abgelaufen“ | Zwischen Klick und Rückkehr sind mehr als 10 Minuten vergangen, oder der Cookie ging verloren. Häufigste echte Ursache: `BASE_URL` zeigt auf eine andere Adresse als die, über die der Browser kommt. |
|
| „Der Anmeldevorgang ist abgelaufen“ | Der Cookie mit dem laufenden Vorgang kam nicht zurück. Entweder sind mehr als 10 Minuten vergangen — oder, weit häufiger, `BASE_URL` zeigt auf einen anderen Host als den, über den der Browser kommt. Erkennbar daran, dass in der Adresszeile eine andere Adresse steht als beim Start. Siehe [BASE_URL und die Adresse im Browser](#base_url-und-die-adresse-im-browser-müssen-dieselbe-sein). |
|
||||||
|
| „Du rufst die Anwendung über … auf, BASE_URL zeigt aber auf …“ | Genau der Fall von oben, nur früh erkannt. Die Meldung nennt beide Adressen; `BASE_URL` in der `.env` auf die erste setzen und `docker compose up -d` ausführen. |
|
||||||
| „Das Anmelde-Token des Anbieters ist ungültig“ | Uhrzeit-Abweichung zwischen den Servern oder ein gewechselter Signaturschlüssel. Beides zeigt `docker compose logs app` genauer. |
|
| „Das Anmelde-Token des Anbieters ist ungültig“ | Uhrzeit-Abweichung zwischen den Servern oder ein gewechselter Signaturschlüssel. Beides zeigt `docker compose logs app` genauer. |
|
||||||
| Nach der Anmeldung fehlt die Verwaltung | `OIDC_ADMIN_GROUP` ist gesetzt, der Benutzer aber nicht in dieser Gruppe. Entweder die Gruppe in Authentik ergänzen oder die Variable leeren und das Recht hier vergeben. |
|
| Nach der Anmeldung fehlt die Verwaltung | `OIDC_ADMIN_GROUP` ist gesetzt, der Benutzer aber nicht in dieser Gruppe. Entweder die Gruppe in Authentik ergänzen oder die Variable leeren und das Recht hier vergeben. |
|
||||||
|
|
||||||
|
|||||||
+139
-6
@@ -19,6 +19,7 @@ so entschieden ist. Wie man es bedient, steht in der
|
|||||||
- [Anmeldung, Gruppen und Rollen](#anmeldung-gruppen-und-rollen)
|
- [Anmeldung, Gruppen und Rollen](#anmeldung-gruppen-und-rollen)
|
||||||
- [Single Sign-on](#single-sign-on)
|
- [Single Sign-on](#single-sign-on)
|
||||||
- [Mehrere Domains](#mehrere-domains)
|
- [Mehrere Domains](#mehrere-domains)
|
||||||
|
- [Erscheinungsbild](#erscheinungsbild)
|
||||||
- [Verwaltung](#verwaltung)
|
- [Verwaltung](#verwaltung)
|
||||||
- [Betrieb](#betrieb)
|
- [Betrieb](#betrieb)
|
||||||
- [Schnittstelle](#schnittstelle)
|
- [Schnittstelle](#schnittstelle)
|
||||||
@@ -101,12 +102,61 @@ 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 |
|
||||||
|
|
||||||
Der **Slug** ist acht Zeichen lang aus einem Alphabet ohne verwechselbare
|
### Ein statischer Code mit Namen
|
||||||
Zeichen (kein `0`, `O`, `1`, `l`, `I`). Er ist **je Domain** eindeutig, nicht
|
|
||||||
global — derselbe Slug darf auf zwei Domains liegen und dort auf
|
Beide Betriebsarten haben ein Feld **Name (optional)**. Es tut zweierlei:
|
||||||
Verschiedenes zeigen.
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
Ohne eigene Angabe entsteht er aus acht Zeichen eines Alphabets ohne
|
||||||
|
verwechselbare Zeichen (kein `0`, `O`, `1`, `l`, `I`) — Kurz-URLs werden
|
||||||
|
gelegentlich abgetippt.
|
||||||
|
|
||||||
|
**Selbst bestimmen** lässt er sich beim Anlegen und später ändern:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| Erlaubt | Kleinbuchstaben, Ziffern, Bindestriche, `/` als Trenner |
|
||||||
|
| Beispiele | `sommeraktion`, `tisch-14`, `kunde-a/flyer` |
|
||||||
|
| Nicht erlaubt | Umlaute, Leerzeichen, Unterstriche, Bindestrich am Rand eines Abschnitts |
|
||||||
|
| Länge | bis 100 Zeichen, höchstens 3 Abschnitte à 48 Zeichen |
|
||||||
|
|
||||||
|
Groß- und Kleinschreibung spielt beim Aufrufen keine Rolle: `SommerAktion`
|
||||||
|
findet `sommeraktion`. Gespeichert wird kleingeschrieben, und zwei Kurz-Links
|
||||||
|
dürfen sich nicht nur in der Schreibweise unterscheiden.
|
||||||
|
|
||||||
|
**Beim Ändern bleibt die vorherige Adresse gültig.** Das ist der Sinn der
|
||||||
|
Sache: Auf gedruckten Codes steht die alte, und die soll weiter funktionieren.
|
||||||
|
Frühere Adressen stehen im Bearbeiten-Dialog und lassen sich einzeln
|
||||||
|
**freigeben** — für den Fall eines Tippfehlers, mit dem noch nichts gedruckt
|
||||||
|
ist. Eine freigegebene Adresse ist sofort tot und danach wieder vergebbar.
|
||||||
|
Solange sie gehalten wird, kann sie kein anderer Code übernehmen; sonst
|
||||||
|
ließen sich fremde gedruckte Codes umlenken.
|
||||||
|
|
||||||
|
**Wer darf das?** Standardmäßig nur Administratoren — ein Kurz-Link ist eine
|
||||||
|
öffentliche Adresse, und wer ihn ändert, kann Aushänge entwerten. Unter
|
||||||
|
Verwaltung → Einstellungen lässt sich das für Bearbeiter freigeben. Ohne das
|
||||||
|
Recht erscheint das Feld gar nicht; Codes anlegen und deren Ziel ändern geht
|
||||||
|
weiterhin.
|
||||||
|
|
||||||
|
Der Kurz-Link ist **je Domain** eindeutig, nicht global — derselbe darf auf
|
||||||
|
zwei Domains liegen und dort auf Verschiedenes zeigen.
|
||||||
|
|
||||||
Die Weiterleitung antwortet mit **302** und `Cache-Control: no-store`. Ein
|
Die Weiterleitung antwortet mit **302** und `Cache-Control: no-store`. Ein
|
||||||
permanenter Redirect würde in Browsern und Zwischenspeichern hängen bleiben —
|
permanenter Redirect würde in Browsern und Zwischenspeichern hängen bleiben —
|
||||||
@@ -344,10 +394,78 @@ Anwendung leiten.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Erscheinungsbild
|
||||||
|
|
||||||
|
Unter Verwaltung → Erscheinungsbild lassen sich einstellen:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| **Name** | Kopfzeile, Titel des Browser-Tabs, Fußzeile |
|
||||||
|
| **Zusatz in der Fußzeile** | steht hinter dem Namen |
|
||||||
|
| **Akzentfarbe** | neun Vorschläge oder ein eigener Hex-Wert |
|
||||||
|
| **Logo** | PNG, JPEG, WebP oder AVIF bis 2 MB; ersetzt Zeichen und Name |
|
||||||
|
| **Favicon** | PNG, ICO oder WebP bis 512 KB |
|
||||||
|
|
||||||
|
**Wie die Farbe wirkt.** Tailwind übersetzt `bg-indigo-600` zu
|
||||||
|
`background-color: var(--color-indigo-600)`. Die Anwendung überschreibt diese
|
||||||
|
Variablen zur Laufzeit — damit folgen alle rund 60 eingefärbten Stellen der
|
||||||
|
gewählten Farbe, ohne dass eine einzige Klasse angefasst werden müsste.
|
||||||
|
|
||||||
|
Aus der Eingabe übernommen werden nur **Farbton und Sättigung**, nicht die
|
||||||
|
Helligkeit. Die Helligkeitsstufen kommen aus der Vorlage. Das ist Absicht: Auf
|
||||||
|
der Stufe 600 steht weiße Schrift. Käme die Helligkeit aus der Eingabe, würde
|
||||||
|
ein helles Gelb die Kopfzeile unlesbar machen — so bleibt jeder Farbton
|
||||||
|
benutzbar.
|
||||||
|
|
||||||
|
### Hell und dunkel
|
||||||
|
|
||||||
|
Die Anwendung folgt von sich aus der Einstellung des Betriebssystems. In der
|
||||||
|
Kopfzeile steht ein Umschalter mit drei Zuständen:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| **System** | folgt dem Betriebssystem — abends dunkel, morgens hell |
|
||||||
|
| **Hell** | immer hell |
|
||||||
|
| **Dunkel** | immer dunkel |
|
||||||
|
|
||||||
|
Drei statt zwei, weil ein reiner Ein/Aus-Schalter nach dem ersten Klick für
|
||||||
|
immer eine feste Wahl bedeutet. Die Wahl merkt sich der Browser; ein zweiter
|
||||||
|
Tab zieht automatisch nach.
|
||||||
|
|
||||||
|
**Wie es umgesetzt ist.** Nicht über `dark:`-Klassen — die hätten an rund 440
|
||||||
|
Stellen ergänzt werden müssen, und jede neue Zeile müsste daran denken.
|
||||||
|
Stattdessen entscheidet `light-dark()` im CSS, welchen Wert die Farbvariablen
|
||||||
|
tragen; gesteuert wird das über `color-scheme`. Deshalb genügt für den
|
||||||
|
Umschalter ein Attribut am `<html>`-Element — und die Systemwahl funktioniert
|
||||||
|
sogar **ohne JavaScript**.
|
||||||
|
|
||||||
|
Die dunklen Werte sind keine Spiegelung der hellen: Reines Schwarz unter
|
||||||
|
weißer Schrift flimmert. Die Flächen liegen bei 20–26 % Helligkeit im selben
|
||||||
|
leicht blauen Ton wie die helle Reihe. Alle Textkontraste liegen über der
|
||||||
|
Anforderung von 4,5:1 — gemessen, nicht geschätzt.
|
||||||
|
|
||||||
|
Unverändert bleiben die QR-Codes selbst. Deren Farben legt die Gestaltung
|
||||||
|
fest, und ein Code, der hell gedruckt wird, soll in der Vorschau nicht anders
|
||||||
|
aussehen.
|
||||||
|
|
||||||
|
**Auf den Landingpages** gelten Farbe, Favicon und das Farbthema, aber weder Logo noch
|
||||||
|
Navigation. Wer einen Aushang scannt, soll die Seite des Inhalts sehen, nicht
|
||||||
|
die einer Verwaltungssoftware. Ein Logo je Gruppe auf den Landingpages steht
|
||||||
|
als eigener Punkt auf der [Roadmap](roadmap.md).
|
||||||
|
|
||||||
|
**Zu den Dateien.** Logo und Favicon liegen wie alle Uploads in der Datenbank
|
||||||
|
und werden über `/api/uploads/{id}` ausgeliefert — öffentlich, denn sie stehen
|
||||||
|
auf jeder Seite. Beim Ersetzen wird die vorherige Datei gelöscht. SVG bleibt
|
||||||
|
auch hier ausgeschlossen, aus demselben Grund wie bei den übrigen Uploads.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Verwaltung
|
## Verwaltung
|
||||||
|
|
||||||
| Bereich | Was dort geht |
|
| Bereich | Was dort geht |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
| **Einstellungen** | was Bearbeiter dürfen — etwa eigene Kurz-Links |
|
||||||
|
| **Erscheinungsbild** | Name, Logo, Favicon, Akzentfarbe |
|
||||||
| **QR-Typen** | einzelne Typen ausblenden |
|
| **QR-Typen** | einzelne Typen ausblenden |
|
||||||
| **Benutzer** | anlegen, deaktivieren, Passwort setzen, Instanz-Admin vergeben |
|
| **Benutzer** | anlegen, deaktivieren, Passwort setzen, Instanz-Admin vergeben |
|
||||||
| **Gruppen** | anlegen, umbenennen, Mitglieder und deren Rollen pflegen |
|
| **Gruppen** | anlegen, umbenennen, Mitglieder und deren Rollen pflegen |
|
||||||
@@ -382,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:
|
||||||
@@ -398,7 +531,7 @@ Alles, was die Oberfläche tut, geht auch über HTTP:
|
|||||||
| `POST /api/feedback` | Rückmeldung abgeben |
|
| `POST /api/feedback` | Rückmeldung abgeben |
|
||||||
| `GET/POST /api/auth` | Zustand, Anmelden, Abmelden, Passwort ändern |
|
| `GET/POST /api/auth` | Zustand, Anmelden, Abmelden, Passwort ändern |
|
||||||
| `GET /api/auth/oidc/start` · `/callback` | Anmeldung über den Anbieter |
|
| `GET /api/auth/oidc/start` · `/callback` | Anmeldung über den Anbieter |
|
||||||
| `/api/admin/types` · `users` · `groups` · `domains` | Verwaltung |
|
| `/api/admin/types` · `users` · `groups` · `domains` · `branding` · `settings` | Verwaltung |
|
||||||
| `GET /r/{slug}` | Weiterleitung eines dynamischen Codes |
|
| `GET /r/{slug}` | Weiterleitung eines dynamischen Codes |
|
||||||
| `GET /s/{slug}` | öffentliche Landingpage |
|
| `GET /s/{slug}` | öffentliche Landingpage |
|
||||||
|
|
||||||
|
|||||||
+16
-62
@@ -7,65 +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.
|
|
||||||
|
|
||||||
### Dokumentation (15.08.2026)
|
|
||||||
|
|
||||||
Bedienungsanleitung mit Bildern, Authentik-Anleitung, Funktionsübersicht.
|
|
||||||
Die Bilder erzeugt ein Skript, damit sie nicht veralten.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -190,10 +141,13 @@ sehr viele oder sehr große Dateien ablegt, will sie irgendwann auf einem
|
|||||||
Objektspeicher (S3) haben. Der Wechsel betrifft nur `src/lib/uploads.ts` und
|
Objektspeicher (S3) haben. Der Wechsel betrifft nur `src/lib/uploads.ts` und
|
||||||
die Auslieferungsroute — aber auch die Sicherungsstrategie.
|
die Auslieferungsroute — aber auch die Sicherungsstrategie.
|
||||||
|
|
||||||
**Eigene Gestaltung der Landingpages** · groß
|
**Erscheinungsbild je Gruppe** · groß
|
||||||
Logo, Farben und Schrift je Gruppe, damit die Seite nach der eigenen Firma
|
Seit dem 16.08. lassen sich Name, Logo, Favicon und Farbe für die ganze
|
||||||
aussieht. Technisch überschaubar, gestalterisch eine eigene Aufgabe — und es
|
Installation einstellen. Der nächste Schritt wäre, das je Gruppe zu erlauben
|
||||||
braucht Grenzen, damit nichts Unlesbares entsteht.
|
und die Landingpages mit dem Logo der jeweiligen Gruppe zu versehen — damit
|
||||||
|
eine gescannte Seite nach der Firma aussieht, der sie gehört. Technisch
|
||||||
|
überschaubar, gestalterisch eine eigene Aufgabe; es braucht Grenzen, damit
|
||||||
|
nichts Unlesbares entsteht.
|
||||||
|
|
||||||
**Mehrsprachige Oberfläche** · groß
|
**Mehrsprachige Oberfläche** · groß
|
||||||
Die Anwendung ist durchgehend deutsch, auch in den Fehlermeldungen und den
|
Die Anwendung ist durchgehend deutsch, auch in den Fehlermeldungen und den
|
||||||
|
|||||||
+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,31 @@
|
|||||||
|
-- Erscheinungsbild: Name, Logo, Favicon und Akzentfarbe der Installation.
|
||||||
|
|
||||||
|
-- Wozu eine hochgeladene Datei gehört. Bestehende Dateien hängen alle an
|
||||||
|
-- einem Code, deshalb ist die Voreinstellung richtig.
|
||||||
|
ALTER TABLE "Upload" ADD COLUMN "purpose" TEXT NOT NULL DEFAULT 'code';
|
||||||
|
CREATE INDEX "Upload_purpose_idx" ON "Upload"("purpose");
|
||||||
|
|
||||||
|
CREATE TABLE "Branding" (
|
||||||
|
"id" TEXT NOT NULL DEFAULT 'branding',
|
||||||
|
"appName" TEXT NOT NULL DEFAULT 'QR-Code-Generator',
|
||||||
|
"footerText" TEXT NOT NULL DEFAULT 'statische und dynamische QR-Codes',
|
||||||
|
"accentColor" TEXT NOT NULL DEFAULT '#4F46E5',
|
||||||
|
"logoUploadId" TEXT,
|
||||||
|
"faviconUploadId" TEXT,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Branding_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "Branding_logoUploadId_key" ON "Branding"("logoUploadId");
|
||||||
|
CREATE UNIQUE INDEX "Branding_faviconUploadId_key" ON "Branding"("faviconUploadId");
|
||||||
|
|
||||||
|
ALTER TABLE "Branding" ADD CONSTRAINT "Branding_logoUploadId_fkey"
|
||||||
|
FOREIGN KEY ("logoUploadId") REFERENCES "Upload"("id")
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "Branding" ADD CONSTRAINT "Branding_faviconUploadId_fkey"
|
||||||
|
FOREIGN KEY ("faviconUploadId") REFERENCES "Upload"("id")
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Die eine Zeile gleich anlegen, damit die Anwendung sie nur noch lesen muss.
|
||||||
|
INSERT INTO "Branding" ("id", "updatedAt") VALUES ('branding', CURRENT_TIMESTAMP);
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
-- Selbst gewählte Kurz-Links, umbenennbar, mit weiterhin gültigen Vorgängern.
|
||||||
|
|
||||||
|
-- Frühere Kurz-Links eines Codes. Ohne sie würde jedes Umbenennen die bereits
|
||||||
|
-- gedruckten Codes entwerten.
|
||||||
|
CREATE TABLE "QrCodeAlias" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"domainId" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"qrCodeId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "QrCodeAlias_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "QrCodeAlias_domainId_slug_key" ON "QrCodeAlias"("domainId", "slug");
|
||||||
|
CREATE INDEX "QrCodeAlias_qrCodeId_idx" ON "QrCodeAlias"("qrCodeId");
|
||||||
|
|
||||||
|
ALTER TABLE "QrCodeAlias" ADD CONSTRAINT "QrCodeAlias_domainId_fkey"
|
||||||
|
FOREIGN KEY ("domainId") REFERENCES "Domain"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "QrCodeAlias" ADD CONSTRAINT "QrCodeAlias_qrCodeId_fkey"
|
||||||
|
FOREIGN KEY ("qrCodeId") REFERENCES "QrCode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Gross- und Kleinschreibung soll beim Aufrufen keine Rolle spielen. Damit
|
||||||
|
-- „Sommer" und „sommer" nicht als zwei verschiedene Kurz-Links vergeben werden
|
||||||
|
-- koennen, gilt die Eindeutigkeit ab jetzt auf der kleingeschriebenen Form.
|
||||||
|
--
|
||||||
|
-- Zuerst pruefen, ob der bestehende Bestand das ueberhaupt zulaesst. Bei
|
||||||
|
-- erzeugten Kurz-Links aus acht Zeichen ist eine solche Kollision praktisch
|
||||||
|
-- ausgeschlossen; ein stiller Datenverlust waere aber unentschuldbar, deshalb
|
||||||
|
-- bricht die Migration in dem Fall lieber ab.
|
||||||
|
DO $$
|
||||||
|
DECLARE kollisionen INT;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO kollisionen FROM (
|
||||||
|
SELECT "domainId", LOWER("slug")
|
||||||
|
FROM "QrCode"
|
||||||
|
GROUP BY "domainId", LOWER("slug")
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
) AS doppelt;
|
||||||
|
|
||||||
|
IF kollisionen > 0 THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'Es gibt % Kurz-Links, die sich nur in der Gross-/Kleinschreibung unterscheiden. Bitte einen davon umbenennen und die Migration erneut starten.',
|
||||||
|
kollisionen;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Der bestehende Index bleibt: Er traegt die exakten Nachschlagevorgaenge, und
|
||||||
|
-- das ist der haeufige Fall — ein gescannter Code liefert die Adresse genau so,
|
||||||
|
-- wie sie gespeichert ist. Der zweite Index kommt dazu und sorgt dafuer, dass
|
||||||
|
-- sich zwei Kurz-Links nicht nur in der Gross-/Kleinschreibung unterscheiden
|
||||||
|
-- koennen.
|
||||||
|
CREATE UNIQUE INDEX "QrCode_domainId_lower_slug_key"
|
||||||
|
ON "QrCode"("domainId", LOWER("slug"));
|
||||||
|
CREATE UNIQUE INDEX "QrCodeAlias_domainId_lower_slug_key"
|
||||||
|
ON "QrCodeAlias"("domainId", LOWER("slug"));
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Verhalten der Installation, das sich nicht aus dem Code ergibt.
|
||||||
|
|
||||||
|
CREATE TABLE "InstanceSetting" (
|
||||||
|
"id" TEXT NOT NULL DEFAULT 'instance',
|
||||||
|
"editorsMaySetSlug" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "InstanceSetting_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Die eine Zeile gleich anlegen, damit die Anwendung sie nur lesen muss.
|
||||||
|
INSERT INTO "InstanceSetting" ("id", "updatedAt") VALUES ('instance', CURRENT_TIMESTAMP);
|
||||||
@@ -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;
|
||||||
+82
-3
@@ -122,6 +122,7 @@ model Domain {
|
|||||||
|
|
||||||
grants DomainGrant[]
|
grants DomainGrant[]
|
||||||
codes QrCode[]
|
codes QrCode[]
|
||||||
|
aliases QrCodeAlias[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Zuteilung einer Domain an eine Gruppe. Eine Domain kann mehreren Gruppen
|
/// Zuteilung einer Domain an eine Gruppe. Eine Domain kann mehreren Gruppen
|
||||||
@@ -161,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
|
||||||
@@ -194,11 +203,14 @@ model QrCode {
|
|||||||
uploads Upload[]
|
uploads Upload[]
|
||||||
/// Rückmeldungen, die über die Landingpage eingegangen sind.
|
/// Rückmeldungen, die über die Landingpage eingegangen sind.
|
||||||
feedback Feedback[]
|
feedback Feedback[]
|
||||||
|
/// Frühere Kurz-Links, die weiterhin hierher führen.
|
||||||
|
aliases QrCodeAlias[]
|
||||||
|
|
||||||
@@unique([domainId, slug])
|
@@unique([domainId, slug])
|
||||||
@@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
|
||||||
@@ -219,14 +231,81 @@ model Upload {
|
|||||||
data Bytes
|
data Bytes
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
/// „code" gehört zu einem QR-Code, „branding" zur Oberfläche. Nur die
|
||||||
|
/// erste Art wird als herrenlos weggeräumt.
|
||||||
|
purpose String @default("code")
|
||||||
|
|
||||||
/// Der Code, zu dem die Datei gehört. Leer, solange sie nur hochgeladen,
|
/// Der Code, zu dem die Datei gehört. Leer, solange sie nur hochgeladen,
|
||||||
/// der Code aber noch nicht gespeichert wurde; solche Reste räumt
|
/// der Code aber noch nicht gespeichert wurde; solche Reste räumt
|
||||||
/// `src/lib/uploads.ts` nach einer Weile weg.
|
/// `src/lib/uploads.ts` nach einer Weile weg.
|
||||||
qrCodeId String?
|
qrCodeId String?
|
||||||
qrCode QrCode? @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
|
qrCode QrCode? @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
brandingLogo Branding? @relation("BrandingLogo")
|
||||||
|
brandingFavicon Branding? @relation("BrandingFavicon")
|
||||||
|
|
||||||
@@index([qrCodeId])
|
@@index([qrCodeId])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
|
@@index([purpose])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verhalten der Installation, das sich nicht aus dem Code ergibt.
|
||||||
|
///
|
||||||
|
/// Genau eine Zeile mit fester Id — wie `Branding`, nur für Verhalten statt
|
||||||
|
/// Aussehen. Ein eigenes Modell statt Schlüssel-Wert-Paaren, damit die Felder
|
||||||
|
/// ihren Typ behalten.
|
||||||
|
model InstanceSetting {
|
||||||
|
id String @id @default("instance")
|
||||||
|
/// Dürfen Bearbeiter den Kurz-Link selbst vergeben und ändern?
|
||||||
|
///
|
||||||
|
/// Aus, weil ein Kurz-Link eine öffentliche Adresse ist: Wer ihn ändert,
|
||||||
|
/// entwertet im Zweifel gedruckte Codes oder besetzt Adressen, die
|
||||||
|
/// jemand anderes gebraucht hätte. Administratoren können das freigeben.
|
||||||
|
editorsMaySetSlug Boolean @default(false)
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein früherer Kurz-Link, der weiterhin auf seinen Code führt.
|
||||||
|
///
|
||||||
|
/// Ohne das würde jedes Umbenennen die bereits gedruckten Codes entwerten —
|
||||||
|
/// und genau deren Unveränderlichkeit ist der Sinn eines dynamischen Codes.
|
||||||
|
/// Freigeben lässt sich ein solcher Eintrag von Hand, etwa nach einem
|
||||||
|
/// Tippfehler, den noch niemand gedruckt hat.
|
||||||
|
model QrCodeAlias {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
/// Mitgeführt, damit die Eindeutigkeit je Domain gilt — wie beim Code selbst.
|
||||||
|
domainId String
|
||||||
|
domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade)
|
||||||
|
slug String
|
||||||
|
qrCodeId String
|
||||||
|
qrCode QrCode @relation(fields: [qrCodeId], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([domainId, slug])
|
||||||
|
@@index([qrCodeId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erscheinungsbild der Installation — Name, Logo, Favicon, Akzentfarbe.
|
||||||
|
///
|
||||||
|
/// Genau eine Zeile mit fester Id. Ein eigenes Modell statt einzelner
|
||||||
|
/// Schlüssel-Wert-Paare, damit die Felder ihren Typ behalten und die
|
||||||
|
/// Beziehungen zu den hochgeladenen Dateien echte Fremdschlüssel sind.
|
||||||
|
model Branding {
|
||||||
|
id String @id @default("branding")
|
||||||
|
appName String @default("QR-Code-Generator")
|
||||||
|
/// Zeile in der Fußzeile, hinter dem Namen.
|
||||||
|
footerText String @default("statische und dynamische QR-Codes")
|
||||||
|
/// Akzentfarbe als Hex. Übernommen werden daraus Farbton und Sättigung;
|
||||||
|
/// die Helligkeitsstufen stammen aus der Vorlage — siehe
|
||||||
|
/// src/lib/branding-colors.ts.
|
||||||
|
accentColor String @default("#4F46E5")
|
||||||
|
|
||||||
|
logoUploadId String? @unique
|
||||||
|
logoUpload Upload? @relation("BrandingLogo", fields: [logoUploadId], references: [id], onDelete: SetNull)
|
||||||
|
faviconUploadId String? @unique
|
||||||
|
faviconUpload Upload? @relation("BrandingFavicon", fields: [faviconUploadId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Eine Rückmeldung, die jemand über die Landingpage eines Feedback-Codes
|
/// Eine Rückmeldung, die jemand über die Landingpage eines Feedback-Codes
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
}
|
||||||
@@ -94,12 +94,16 @@ export default async function SignInPage({ searchParams }: PageProps) {
|
|||||||
Gruppen, Benutzer und die QR-Typen verwalten.
|
Gruppen, Benutzer und die QR-Typen verwalten.
|
||||||
</Notice>
|
</Notice>
|
||||||
) : oidc?.nurOidc ? (
|
) : oidc?.nurOidc ? (
|
||||||
|
/* Bewusst kein Hinweis darauf, dass die Passwort-Anmeldung
|
||||||
|
abgeschaltet ist. Diese Seite sieht jeder, der die Adresse kennt —
|
||||||
|
wie die Anmeldung eingerichtet ist, geht Fremde nichts an. Wer
|
||||||
|
hierher gehört, sieht den einen Knopf und weiß, was zu tun ist.
|
||||||
|
Beim allerersten Aufruf ist der Hinweis dagegen nützlich. */
|
||||||
|
setup ? (
|
||||||
<p className="text-center text-sm text-slate-500">
|
<p className="text-center text-sm text-slate-500">
|
||||||
Die Anmeldung mit Passwort ist abgeschaltet (<code>OIDC_ONLY=true</code>).
|
Wer sich zuerst anmeldet, wird Administrator dieser Installation.
|
||||||
{setup
|
|
||||||
? " Wer sich zuerst anmeldet, wird Administrator dieser Installation."
|
|
||||||
: ""}
|
|
||||||
</p>
|
</p>
|
||||||
|
) : null
|
||||||
) : (
|
) : (
|
||||||
<p className="text-center text-sm text-slate-500">
|
<p className="text-center text-sm text-slate-500">
|
||||||
Passwort vergessen? Ein Administrator kann es unter Verwaltung →
|
Passwort vergessen? Ein Administrator kann es unter Verwaltung →
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ 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 { can } from "@/lib/auth/policy";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
import { requirePageActor } from "@/lib/auth/guard";
|
import { requirePageActor } from "@/lib/auth/guard";
|
||||||
import { getQrTypeState } from "@/lib/qr/availability";
|
import { getQrTypeState } from "@/lib/qr/availability";
|
||||||
import { getQrType } from "@/lib/qr/registry";
|
import { getQrType } from "@/lib/qr/registry";
|
||||||
@@ -47,7 +50,7 @@ function Hinweis({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function CreatePage({ params }: Props) {
|
export default async function CreatePage({ params }: Props) {
|
||||||
await requirePageActor();
|
const actor = await requirePageActor();
|
||||||
const { type } = await params;
|
const { type } = await params;
|
||||||
const state = await getQrTypeState(type);
|
const state = await getQrTypeState(type);
|
||||||
|
|
||||||
@@ -75,5 +78,12 @@ export default async function CreatePage({ params }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <QrGenerator typeId={state.qrType.id} />;
|
// Ob der Kurz-Link selbst vergeben werden darf, haengt an Rolle und
|
||||||
|
// Einstellung. Das Feld erscheint nur dann — die Route prueft es ohnehin
|
||||||
|
// noch einmal.
|
||||||
|
const { editorsMaySetSlug } = await getSettings();
|
||||||
|
const groupId = await resolveTargetGroup(actor, undefined).catch(() => undefined);
|
||||||
|
const maySetSlug = can(actor, "codes:slug", { groupId, editorsMaySetSlug });
|
||||||
|
|
||||||
|
return <QrGenerator typeId={state.qrType.id} maySetSlug={maySetSlug} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,11 +37,11 @@ export default async function AccountPage() {
|
|||||||
<span className="font-medium">Die Anmeldung ist ausgeschaltet.</span>{" "}
|
<span className="font-medium">Die Anmeldung ist ausgeschaltet.</span>{" "}
|
||||||
Es gibt deshalb kein Konto und niemanden zum Abmelden — wer die
|
Es gibt deshalb kein Konto und niemanden zum Abmelden — wer die
|
||||||
Anwendung erreicht, darf alles. Zum Einschalten{" "}
|
Anwendung erreicht, darf alles. Zum Einschalten{" "}
|
||||||
<code className="rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">
|
<code className="rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
|
||||||
AUTH_ENABLED=true
|
AUTH_ENABLED=true
|
||||||
</code>{" "}
|
</code>{" "}
|
||||||
in der <code className="font-mono text-xs">.env</code> setzen und{" "}
|
in der <code className="font-mono text-xs">.env</code> setzen und{" "}
|
||||||
<code className="rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">
|
<code className="rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
</code>{" "}
|
</code>{" "}
|
||||||
ausführen. Beim nächsten Aufruf legst du den ersten Administrator
|
ausführen. Beim nächsten Aufruf legst du den ersten Administrator
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import Link from "next/link";
|
|||||||
|
|
||||||
import { LogoutButton } from "@/components/auth-forms";
|
import { LogoutButton } from "@/components/auth-forms";
|
||||||
import { Icon } from "@/components/icons";
|
import { Icon } from "@/components/icons";
|
||||||
|
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 { 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.
|
||||||
@@ -21,24 +24,43 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
|
|||||||
const actor = await getActor();
|
const actor = await getActor();
|
||||||
const signedIn = actor.kind !== "anonymous";
|
const signedIn = actor.kind !== "anonymous";
|
||||||
const showAdmin = can(actor, "instance:manage");
|
const showAdmin = can(actor, "instance:manage");
|
||||||
|
const branding = await getBranding();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<header className="border-b border-slate-200 bg-white">
|
<header className="border-b border-slate-200 bg-surface">
|
||||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3.5 sm:px-6">
|
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3.5 sm:px-6">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="flex items-center gap-2.5 rounded-lg font-semibold text-slate-900"
|
className="flex items-center gap-2.5 rounded-lg font-semibold text-slate-900"
|
||||||
>
|
>
|
||||||
|
{branding.logoUploadId ? (
|
||||||
|
/* eslint-disable-next-line @next/next/no-img-element -- aus der
|
||||||
|
Datenbank ausgeliefert, nicht aus dem Dateisystem */
|
||||||
|
<img
|
||||||
|
src={`/api/uploads/${branding.logoUploadId}`}
|
||||||
|
alt={branding.appName}
|
||||||
|
className="h-9 w-auto max-w-[12rem] object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<span className="flex size-9 items-center justify-center rounded-lg bg-indigo-600 text-white">
|
<span className="flex size-9 items-center justify-center rounded-lg bg-indigo-600 text-white">
|
||||||
<Icon name="qr" className="size-5" />
|
<Icon name="qr" className="size-5" />
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
{/* Auf schmalen Geräten reicht das Zeichen — sonst passt die
|
{/* Auf schmalen Geräten reicht das Zeichen — sonst passt die
|
||||||
Navigation nicht mehr in eine Zeile. */}
|
Navigation nicht mehr in eine Zeile. Mit eigenem Logo trägt
|
||||||
<span className="hidden sm:inline">QR-Code-Generator</span>
|
dieses schon den Namen. */}
|
||||||
|
{branding.logoUploadId ? null : (
|
||||||
|
<span className="hidden sm:inline">{branding.appName}</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="flex flex-wrap items-center gap-0.5 text-sm font-medium sm:gap-1">
|
<nav className="flex flex-wrap items-center gap-0.5 text-sm font-medium sm:gap-1">
|
||||||
|
<ThemeToggle />
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="mx-1 hidden h-5 w-px bg-slate-200 sm:block"
|
||||||
|
/>
|
||||||
{signedIn ? (
|
{signedIn ? (
|
||||||
<>
|
<>
|
||||||
<Link href="/" className={NAV_LINK}>
|
<Link href="/" className={NAV_LINK}>
|
||||||
@@ -66,7 +88,7 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
|
|||||||
title={`Konto von ${actor.user.email}`}
|
title={`Konto von ${actor.user.email}`}
|
||||||
className="flex max-w-[13rem] items-center gap-2 rounded-lg px-2.5 py-2 text-slate-700 transition-colors hover:bg-slate-100"
|
className="flex max-w-[13rem] items-center gap-2 rounded-lg px-2.5 py-2 text-slate-700 transition-colors hover:bg-slate-100"
|
||||||
>
|
>
|
||||||
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-indigo-100 text-[0.7rem] font-semibold text-indigo-700 uppercase">
|
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-accent-soft text-[0.7rem] font-semibold text-accent-soft-fg uppercase">
|
||||||
{actorLabel(actor).slice(0, 2)}
|
{actorLabel(actor).slice(0, 2)}
|
||||||
</span>
|
</span>
|
||||||
<span className="hidden truncate sm:inline">
|
<span className="hidden truncate sm:inline">
|
||||||
@@ -106,9 +128,21 @@ 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-white">
|
<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">
|
||||||
QR-Code-Generator · statische und dynamische QR-Codes
|
<span>
|
||||||
|
{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>
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export default async function FeedbackResultsPage({ params }: PageProps) {
|
|||||||
{kommentare.map((eintrag) => (
|
{kommentare.map((eintrag) => (
|
||||||
<li
|
<li
|
||||||
key={eintrag.id}
|
key={eintrag.id}
|
||||||
className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm"
|
className="rounded-xl border border-slate-200 bg-surface p-4 shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="mb-1.5 flex items-center gap-2 text-sm text-slate-500">
|
<div className="mb-1.5 flex items-center gap-2 text-sm text-slate-500">
|
||||||
<span className="font-medium text-slate-900 tabular-nums">
|
<span className="font-medium text-slate-900 tabular-nums">
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
import { CodesList } from "@/components/codes-list";
|
import { CodesList } from "@/components/codes-list";
|
||||||
|
import { can } from "@/lib/auth/policy";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
import { requirePageActor } from "@/lib/auth/guard";
|
import { requirePageActor } from "@/lib/auth/guard";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Meine Codes" };
|
export const metadata: Metadata = { title: "Meine Codes" };
|
||||||
@@ -8,10 +10,16 @@ export const metadata: Metadata = { title: "Meine Codes" };
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function MyCodesPage() {
|
export default async function MyCodesPage() {
|
||||||
await requirePageActor();
|
const actor = await requirePageActor();
|
||||||
|
|
||||||
// Breiter als die übrigen Seiten: Die Liste soll viele Projekte
|
// Breiter als die übrigen Seiten: Die Liste soll viele Projekte
|
||||||
// nebeneinander zeigen können.
|
// nebeneinander zeigen können.
|
||||||
|
// Der Kurz-Link darf je Gruppe verschieden bearbeitbar sein; hier zaehlt,
|
||||||
|
// ob es irgendwo erlaubt ist. Die Route prueft es beim Speichern erneut,
|
||||||
|
// dann mit der Gruppe des betroffenen Codes.
|
||||||
|
const { editorsMaySetSlug } = await getSettings();
|
||||||
|
const maySetSlug = can(actor, "codes:slug", { editorsMaySetSlug });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-10">
|
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-10">
|
||||||
<div className="mb-7">
|
<div className="mb-7">
|
||||||
@@ -25,7 +33,7 @@ export default async function MyCodesPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CodesList />
|
<CodesList maySetSlug={maySetSlug} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default async function HomePage() {
|
|||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
usable
|
usable
|
||||||
? "flex size-11 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600"
|
? "flex size-11 shrink-0 items-center justify-center rounded-xl bg-accent-soft text-accent-text"
|
||||||
: "flex size-11 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-400"
|
: "flex size-11 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-400"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -96,7 +96,7 @@ export default async function HomePage() {
|
|||||||
{usable ? (
|
{usable ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/erstellen/${qrType.id}`}
|
href={`/erstellen/${qrType.id}`}
|
||||||
className="flex h-full items-start gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
|
className="flex h-full items-start gap-4 rounded-2xl border border-slate-200 bg-surface p-4 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
|
||||||
>
|
>
|
||||||
{tile}
|
{tile}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import { SettingsAdmin } from "@/components/settings-admin";
|
||||||
|
import { requirePagePermission } from "@/lib/auth/guard";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Einstellungen" };
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function SettingsPage() {
|
||||||
|
await requirePagePermission("instance:manage");
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
|
||||||
|
<Link
|
||||||
|
href="/verwaltung"
|
||||||
|
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 Verwaltung
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-7">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||||
|
Einstellungen
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-slate-600">
|
||||||
|
Was Bearbeiter dürfen und was Administratoren vorbehalten bleibt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SettingsAdmin initial={{ editorsMaySetSlug: settings.editorsMaySetSlug }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { BrandingAdmin } from "@/components/branding-admin";
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import { requirePagePermission } from "@/lib/auth/guard";
|
||||||
|
import { getBranding } from "@/lib/branding";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Erscheinungsbild" };
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function BrandingPage() {
|
||||||
|
await requirePagePermission("instance:manage");
|
||||||
|
const branding = await getBranding();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
|
||||||
|
<Link
|
||||||
|
href="/verwaltung"
|
||||||
|
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 Verwaltung
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-7">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||||
|
Erscheinungsbild
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-slate-600">
|
||||||
|
Name, Logo, Favicon und Farbe der Oberfläche. Die Einstellungen
|
||||||
|
gelten für die ganze Installation.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
|
Die öffentlichen Landingpages übernehmen die Farbe und das Favicon,
|
||||||
|
aber weder Logo noch Navigation — wer einen Code scannt, soll die
|
||||||
|
Seite des Inhalts sehen und nicht die einer Verwaltungssoftware.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BrandingAdmin
|
||||||
|
initial={{
|
||||||
|
appName: branding.appName,
|
||||||
|
footerText: branding.footerText,
|
||||||
|
accentColor: branding.accentColor,
|
||||||
|
logoUploadId: branding.logoUploadId,
|
||||||
|
faviconUploadId: branding.faviconUploadId,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import { Icon, type IconName } from "@/components/icons";
|
|||||||
import { Notice } from "@/components/ui";
|
import { Notice } from "@/components/ui";
|
||||||
import { isAuthEnabled } from "@/lib/auth/actor";
|
import { isAuthEnabled } from "@/lib/auth/actor";
|
||||||
import { requirePagePermission } from "@/lib/auth/guard";
|
import { requirePagePermission } from "@/lib/auth/guard";
|
||||||
|
import { getBranding } from "@/lib/branding";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Verwaltung" };
|
export const metadata: Metadata = { title: "Verwaltung" };
|
||||||
@@ -26,9 +28,9 @@ function Tile({
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
className="flex items-start gap-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
|
className="flex items-start gap-4 rounded-2xl border border-slate-200 bg-surface p-5 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
|
||||||
>
|
>
|
||||||
<span className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600">
|
<span className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-accent-soft text-accent-text">
|
||||||
<Icon name={icon} className="size-6" />
|
<Icon name={icon} className="size-6" />
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
@@ -45,13 +47,23 @@ function Tile({
|
|||||||
export default async function AdminPage() {
|
export default async function AdminPage() {
|
||||||
await requirePagePermission("instance:manage");
|
await requirePagePermission("instance:manage");
|
||||||
|
|
||||||
const [userCount, groupCount, codeCount, domainCount] = await Promise.all([
|
const [userCount, groupCount, codeCount, domainCount, branding, settings] =
|
||||||
|
await Promise.all([
|
||||||
prisma.user.count(),
|
prisma.user.count(),
|
||||||
prisma.group.count(),
|
prisma.group.count(),
|
||||||
prisma.qrCode.count(),
|
prisma.qrCode.count(),
|
||||||
prisma.domain.count(),
|
prisma.domain.count(),
|
||||||
|
getBranding(),
|
||||||
|
getSettings(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const eigenes = [
|
||||||
|
branding.logoUploadId && "Logo",
|
||||||
|
branding.faviconUploadId && "Favicon",
|
||||||
|
].filter(Boolean);
|
||||||
|
const brandingHint =
|
||||||
|
eigenes.length > 0 ? `${eigenes.join(" und ")} hinterlegt` : branding.appName;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-10">
|
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-10">
|
||||||
<div className="mb-7">
|
<div className="mb-7">
|
||||||
@@ -68,11 +80,11 @@ export default async function AdminPage() {
|
|||||||
<Notice tone="info">
|
<Notice tone="info">
|
||||||
<span className="font-medium">Die Anmeldung ist ausgeschaltet.</span>{" "}
|
<span className="font-medium">Die Anmeldung ist ausgeschaltet.</span>{" "}
|
||||||
Jeder, der die Anwendung erreicht, darf alles. Zum Einschalten
|
Jeder, der die Anwendung erreicht, darf alles. Zum Einschalten
|
||||||
<code className="mx-1 rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">
|
<code className="mx-1 rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
|
||||||
AUTH_ENABLED=true
|
AUTH_ENABLED=true
|
||||||
</code>
|
</code>
|
||||||
in der <code className="font-mono text-xs">.env</code> setzen und
|
in der <code className="font-mono text-xs">.env</code> setzen und
|
||||||
<code className="mx-1 rounded bg-white/70 px-1.5 py-0.5 font-mono text-xs">
|
<code className="mx-1 rounded bg-surface/70 px-1.5 py-0.5 font-mono text-xs">
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
</code>
|
</code>
|
||||||
ausführen. Beim nächsten Aufruf legst du den ersten Administrator
|
ausführen. Beim nächsten Aufruf legst du den ersten Administrator
|
||||||
@@ -110,6 +122,24 @@ export default async function AdminPage() {
|
|||||||
description="Festlegen, welche Typen auf der Startseite erscheinen."
|
description="Festlegen, welche Typen auf der Startseite erscheinen."
|
||||||
hint="15 Typen insgesamt"
|
hint="15 Typen insgesamt"
|
||||||
/>
|
/>
|
||||||
|
<Tile
|
||||||
|
href="/verwaltung/einstellungen"
|
||||||
|
icon="lock"
|
||||||
|
title="Einstellungen"
|
||||||
|
description="Was Bearbeiter dürfen — etwa eigene Kurz-Links vergeben."
|
||||||
|
hint={
|
||||||
|
settings.editorsMaySetSlug
|
||||||
|
? "Kurz-Links: auch Bearbeiter"
|
||||||
|
: "Kurz-Links: nur Administratoren"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Tile
|
||||||
|
href="/verwaltung/erscheinungsbild"
|
||||||
|
icon="image"
|
||||||
|
title="Erscheinungsbild"
|
||||||
|
description="Name, Logo, Favicon und Farbe der Oberfläche."
|
||||||
|
hint={brandingHint}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getActor } from "@/lib/auth/actor";
|
||||||
|
import { requirePermission } from "@/lib/auth/policy";
|
||||||
|
import { isHexColor } from "@/lib/branding-colors";
|
||||||
|
import {
|
||||||
|
getBranding,
|
||||||
|
MAX_APP_NAME,
|
||||||
|
MAX_FOOTER_TEXT,
|
||||||
|
saveBranding,
|
||||||
|
} from "@/lib/branding";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** `null` löscht das Bild, ein fehlendes Feld lässt es unverändert. */
|
||||||
|
const bildSchema = z.string().trim().min(1).max(64).nullable().optional();
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
appName: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, { error: "Bitte gib einen Namen an." })
|
||||||
|
.max(MAX_APP_NAME, {
|
||||||
|
error: `Der Name darf höchstens ${MAX_APP_NAME} Zeichen lang sein.`,
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
footerText: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(MAX_FOOTER_TEXT, {
|
||||||
|
error: `Der Text darf höchstens ${MAX_FOOTER_TEXT} Zeichen lang sein.`,
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
accentColor: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine(isHexColor, {
|
||||||
|
error: "Die Farbe muss als Hex-Wert angegeben werden, z. B. #4F46E5.",
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
logoUploadId: bildSchema,
|
||||||
|
faviconUploadId: bildSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(): Promise<Response> {
|
||||||
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
|
return Response.json({ branding: await getBranding() });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, dass eine angegebene Datei existiert und wirklich zum
|
||||||
|
* Erscheinungsbild gehört — sonst ließe sich hier die Datei eines fremden
|
||||||
|
* QR-Codes einhängen und damit dessen Anzeige zerstören.
|
||||||
|
*/
|
||||||
|
async function assertBrandingUpload(
|
||||||
|
id: string | null | undefined,
|
||||||
|
feld: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!id) return;
|
||||||
|
const upload = await prisma.upload.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { purpose: true },
|
||||||
|
});
|
||||||
|
if (!upload || upload.purpose !== "branding") {
|
||||||
|
throw new QrRequestError(
|
||||||
|
`${feld} wurde nicht gefunden. Bitte lade die Datei erneut hoch.`,
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest): Promise<Response> {
|
||||||
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
|
|
||||||
|
const parsed = updateSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertBrandingUpload(parsed.data.logoUploadId, "Das Logo");
|
||||||
|
await assertBrandingUpload(parsed.data.faviconUploadId, "Das Favicon");
|
||||||
|
|
||||||
|
return Response.json({ branding: await saveBranding(parsed.data) });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getActor } from "@/lib/auth/actor";
|
||||||
|
import { requirePermission } from "@/lib/auth/policy";
|
||||||
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { getSettings, saveSettings } from "@/lib/settings";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
editorsMaySetSlug: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(): Promise<Response> {
|
||||||
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
|
return Response.json({ settings: await getSettings() });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest): Promise<Response> {
|
||||||
|
try {
|
||||||
|
requirePermission(await getActor(), "instance:manage");
|
||||||
|
|
||||||
|
const parsed = updateSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
|
}
|
||||||
|
if (Object.keys(parsed.data).length === 0) {
|
||||||
|
return jsonError("Es wurden keine Änderungen übermittelt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ settings: await saveSettings(parsed.data) });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { createHash, randomBytes } from "node:crypto";
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
import { isAuthEnabled } from "@/lib/auth/actor";
|
import { isAuthEnabled } from "@/lib/auth/actor";
|
||||||
import {
|
import {
|
||||||
|
assertHostMatchesBaseUrl,
|
||||||
discover,
|
discover,
|
||||||
getOidcConfig,
|
getOidcConfig,
|
||||||
isOidcEnabled,
|
isOidcEnabled,
|
||||||
@@ -26,7 +28,7 @@ function base64url(buffer: Buffer): string {
|
|||||||
* zusätzlich das Geheimnis aus diesem Aufruf nötig ist. Das schützt auch dann,
|
* zusätzlich das Geheimnis aus diesem Aufruf nötig ist. Das schützt auch dann,
|
||||||
* wenn der Code unterwegs sichtbar wird — etwa in einem Proxy-Protokoll.
|
* wenn der Code unterwegs sichtbar wird — etwa in einem Proxy-Protokoll.
|
||||||
*/
|
*/
|
||||||
export async function GET(): Promise<Response> {
|
export async function GET(request: NextRequest): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
if (!isAuthEnabled()) {
|
if (!isAuthEnabled()) {
|
||||||
throw new QrRequestError(
|
throw new QrRequestError(
|
||||||
@@ -41,6 +43,10 @@ export async function GET(): Promise<Response> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vor allem anderen: Kommt der Browser über die Adresse, auf die
|
||||||
|
// BASE_URL zeigt? Sonst geht der Vorgang auf dem Rückweg verloren.
|
||||||
|
assertHostMatchesBaseUrl(request.headers);
|
||||||
|
|
||||||
const config = getOidcConfig();
|
const config = getOidcConfig();
|
||||||
const meta = await discover(config.issuer);
|
const meta = await discover(config.issuer);
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
|||||||
import { requireImplementedQrType } from "@/lib/qr/availability";
|
import { requireImplementedQrType } from "@/lib/qr/availability";
|
||||||
import { designSchema } from "@/lib/qr/design";
|
import { designSchema } from "@/lib/qr/design";
|
||||||
import { assertDynamicPossible } from "@/lib/qr/request";
|
import { assertDynamicPossible } from "@/lib/qr/request";
|
||||||
|
import { describeSlugProblem, normalizeSlug } from "@/lib/slug";
|
||||||
|
import { assertSlugAvailable } from "@/lib/slug-resolve";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
import { attachUploads } from "@/lib/uploads";
|
import { attachUploads } from "@/lib/uploads";
|
||||||
import { QrRequestError } from "@/lib/qr/type-module";
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
@@ -21,6 +24,7 @@ const WITH_RELATIONS = {
|
|||||||
group: { select: { name: true } },
|
group: { select: { name: true } },
|
||||||
domain: { select: { hostname: true } },
|
domain: { select: { hostname: true } },
|
||||||
createdBy: { select: { name: true, email: true } },
|
createdBy: { select: { name: true, email: true } },
|
||||||
|
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const updateSchema = z.object({
|
const updateSchema = z.object({
|
||||||
@@ -32,6 +36,17 @@ const updateSchema = z.object({
|
|||||||
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
|
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
|
||||||
.nullable()
|
.nullable()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
/** Neuer Kurz-Link. Der bisherige bleibt als frühere Adresse gültig. */
|
||||||
|
slug: z.string().trim().max(120).optional(),
|
||||||
|
/**
|
||||||
|
* Welche früheren Adressen weiterhin gelten sollen.
|
||||||
|
*
|
||||||
|
* Bewusst als vollständige Liste und nicht als „lösche diese eine": So
|
||||||
|
* beschreibt die Anfrage den gewünschten Zustand, und die Oberfläche muss
|
||||||
|
* sich nicht merken, was sie schon geschickt hat. Was fehlt, wird
|
||||||
|
* freigegeben — und ist damit sofort tot.
|
||||||
|
*/
|
||||||
|
aliases: z.array(z.string().trim().max(120)).max(50).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadCode(id: string) {
|
async function loadCode(id: string) {
|
||||||
@@ -81,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
|
||||||
@@ -109,15 +133,94 @@ export async function PATCH(
|
|||||||
Object.assign(update, parsed.data.design);
|
Object.assign(update, parsed.data.design);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(update).length === 0) {
|
/* ------------------------------------------------------- Kurz-Link */
|
||||||
|
|
||||||
|
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 =
|
||||||
|
neuerSlug !== undefined &&
|
||||||
|
existing.slug !== null &&
|
||||||
|
normalizeSlug(neuerSlug) !== normalizeSlug(existing.slug);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welche früheren Adressen gelten sollen. Fehlt das Feld, bleibt alles wie
|
||||||
|
* es ist; steht es da, beschreibt es den gewünschten Zustand.
|
||||||
|
*/
|
||||||
|
const behalten =
|
||||||
|
parsed.data.aliases === undefined
|
||||||
|
? null
|
||||||
|
: new Set(parsed.data.aliases.map(normalizeSlug));
|
||||||
|
|
||||||
|
const vorhandeneAliase = await prisma.qrCodeAlias.findMany({
|
||||||
|
where: { qrCodeId: existing.id },
|
||||||
|
select: { id: true, slug: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const freigeben = behalten
|
||||||
|
? vorhandeneAliase.filter((a) => !behalten.has(normalizeSlug(a.slug)))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Erst prüfen, wenn wirklich etwas am Kurz-Link geändert wird. Die
|
||||||
|
// Oberfläche schickt die Liste der früheren Adressen bei jeder Änderung
|
||||||
|
// mit — unverändert soll das kein zusätzliches Recht verlangen.
|
||||||
|
if (umbenannt || freigeben.length > 0) {
|
||||||
|
const { editorsMaySetSlug } = await getSettings();
|
||||||
|
requirePermission(actor, "codes:slug", {
|
||||||
|
groupId: existing.groupId,
|
||||||
|
editorsMaySetSlug,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (umbenannt) {
|
||||||
|
const problem = describeSlugProblem(neuerSlug);
|
||||||
|
if (problem) return jsonError(problem);
|
||||||
|
await assertSlugAvailable(existing.domainId, neuerSlug, existing.id);
|
||||||
|
update.slug = normalizeSlug(neuerSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
Object.keys(update).length === 0 &&
|
||||||
|
!umbenannt &&
|
||||||
|
freigeben.length === 0
|
||||||
|
) {
|
||||||
return jsonError("Es wurden keine Änderungen übermittelt.");
|
return jsonError("Es wurden keine Änderungen übermittelt.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await prisma.qrCode.update({
|
// Umbenennen, alte Adresse sichern und Freigaben in einem Zug — sonst
|
||||||
|
// gäbe es einen Moment, in dem der Code unter keiner Adresse erreichbar
|
||||||
|
// ist, oder eine freigegebene Adresse bliebe hängen.
|
||||||
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
|
if (freigeben.length > 0) {
|
||||||
|
await tx.qrCodeAlias.deleteMany({
|
||||||
|
where: { id: { in: freigeben.map((a) => a.id) } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (umbenannt) {
|
||||||
|
// Der bisherige Kurz-Link bleibt gültig — daran hängen gedruckte
|
||||||
|
// Codes. Freigeben lässt er sich danach einzeln.
|
||||||
|
await tx.qrCodeAlias.create({
|
||||||
|
data: {
|
||||||
|
domainId: existing.domainId,
|
||||||
|
// `umbenannt` gilt nur bei vorhandenem Kurz-Link.
|
||||||
|
slug: existing.slug as string,
|
||||||
|
qrCodeId: existing.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.qrCode.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: update,
|
data: update,
|
||||||
include: WITH_RELATIONS,
|
include: WITH_RELATIONS,
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// 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.
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import { requireAvailableQrType } from "@/lib/qr/availability";
|
|||||||
import { designSchema } from "@/lib/qr/design";
|
import { designSchema } from "@/lib/qr/design";
|
||||||
import { assertDynamicPossible } from "@/lib/qr/request";
|
import { assertDynamicPossible } from "@/lib/qr/request";
|
||||||
import { QR_TYPE_IDS, QrRequestError } from "@/lib/qr/type-module";
|
import { QR_TYPE_IDS, QrRequestError } from "@/lib/qr/type-module";
|
||||||
import { generateSlug } from "@/lib/slug";
|
import { describeSlugProblem, generateSlug, normalizeSlug } from "@/lib/slug";
|
||||||
|
import { assertSlugAvailable } from "@/lib/slug-resolve";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
import { attachUploads } from "@/lib/uploads";
|
import { attachUploads } from "@/lib/uploads";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -26,6 +28,17 @@ 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.
|
||||||
|
* Geprüft wird er in `describeSlugProblem` — hier nur die grobe Länge.
|
||||||
|
*/
|
||||||
|
slug: z.string().trim().max(120).optional(),
|
||||||
/** In welcher Gruppe der Code angelegt wird. Ohne Anmeldung bedeutungslos. */
|
/** In welcher Gruppe der Code angelegt wird. Ohne Anmeldung bedeutungslos. */
|
||||||
groupId: z.string().trim().min(1).optional(),
|
groupId: z.string().trim().min(1).optional(),
|
||||||
/** Unter welcher Domain die Kurz-URL entsteht. */
|
/** Unter welcher Domain die Kurz-URL entsteht. */
|
||||||
@@ -57,6 +70,7 @@ export async function GET(): Promise<Response> {
|
|||||||
group: { select: { name: true } },
|
group: { select: { name: true } },
|
||||||
domain: { select: { hostname: true } },
|
domain: { select: { hostname: true } },
|
||||||
createdBy: { select: { name: true, email: true } },
|
createdBy: { select: { name: true, email: true } },
|
||||||
|
aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,15 +107,44 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
return jsonError(validated.message);
|
return jsonError(validated.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const istStatisch = parsed.data.mode === "static";
|
||||||
|
|
||||||
|
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.
|
// Erst hier, nach der Validierung: die Probe braucht die geprüften Daten.
|
||||||
assertDynamicPossible(qrType, validated.data);
|
assertDynamicPossible(qrType, validated.data);
|
||||||
|
}
|
||||||
|
|
||||||
// Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich.
|
// Ein selbst gewählter Kurz-Link wird geprüft, bevor irgendetwas entsteht —
|
||||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
// sonst stünde am Ende ein Code mit einer Adresse, die niemand wollte.
|
||||||
try {
|
const gewuenscht = parsed.data.slug?.trim();
|
||||||
|
if (gewuenscht) {
|
||||||
|
// Einen Kurz-Link selbst zu vergeben wiegt mehr als einen Code anzulegen.
|
||||||
|
const { editorsMaySetSlug } = await getSettings();
|
||||||
|
requirePermission(actor, "codes:slug", { groupId, editorsMaySetSlug });
|
||||||
|
|
||||||
|
const problem = describeSlugProblem(gewuenscht);
|
||||||
|
if (problem) return jsonError(problem);
|
||||||
|
await assertSlugAvailable(domainId, gewuenscht);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (istStatisch) {
|
||||||
const created = await prisma.qrCode.create({
|
const created = await prisma.qrCode.create({
|
||||||
data: {
|
data: {
|
||||||
slug: generateSlug(),
|
mode: "static",
|
||||||
|
slug: null,
|
||||||
type: qrType.id,
|
type: qrType.id,
|
||||||
label: label && label.length > 0 ? label : null,
|
label: label && label.length > 0 ? label : null,
|
||||||
data: toJsonInput(validated.data),
|
data: toJsonInput(validated.data),
|
||||||
@@ -114,6 +157,32 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
group: { select: { name: true } },
|
group: { select: { name: true } },
|
||||||
domain: { select: { hostname: true } },
|
domain: { select: { hostname: true } },
|
||||||
createdBy: { select: { name: true, email: 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.
|
||||||
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const created = await prisma.qrCode.create({
|
||||||
|
data: {
|
||||||
|
slug: gewuenscht ? normalizeSlug(gewuenscht) : generateSlug(),
|
||||||
|
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" } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Erst jetzt gehören die hochgeladenen Dateien zu diesem Code —
|
// Erst jetzt gehören die hochgeladenen Dateien zu diesem Code —
|
||||||
@@ -122,6 +191,14 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
|
|
||||||
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Bei einem selbst gewählten Kurz-Link hilft kein zweiter Versuch: Er
|
||||||
|
// käme immer wieder auf dieselbe Adresse.
|
||||||
|
if (gewuenscht && isUniqueViolation(error)) {
|
||||||
|
return jsonError(
|
||||||
|
`Der Kurz-Link „${normalizeSlug(gewuenscht)}" ist auf dieser Domain schon vergeben.`,
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!isUniqueViolation(error)) throw error;
|
if (!isUniqueViolation(error)) throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
isUploadKind,
|
isUploadKind,
|
||||||
maxUploadBytes,
|
maxUploadBytes,
|
||||||
storeUpload,
|
storeUpload,
|
||||||
|
uploadPurposeOf,
|
||||||
} from "@/lib/uploads";
|
} from "@/lib/uploads";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -28,10 +29,6 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
|
|
||||||
const actor = await getActor();
|
const actor = await getActor();
|
||||||
|
|
||||||
// Wer keinen Code anlegen darf, soll auch nichts ablegen können.
|
|
||||||
const groupId = await resolveTargetGroup(actor, undefined);
|
|
||||||
requirePermission(actor, "codes:create", { groupId });
|
|
||||||
|
|
||||||
let form: FormData;
|
let form: FormData;
|
||||||
try {
|
try {
|
||||||
form = await request.formData();
|
form = await request.formData();
|
||||||
@@ -44,6 +41,15 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
return jsonError("Unbekannte Dateiart.");
|
return jsonError("Unbekannte Dateiart.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Die Rechte hängen daran, wofür die Datei gedacht ist: Logo und Favicon
|
||||||
|
// gehören der Installation, alles Übrige einem Code in einer Gruppe.
|
||||||
|
if (uploadPurposeOf(kind) === "branding") {
|
||||||
|
requirePermission(actor, "instance:manage");
|
||||||
|
} else {
|
||||||
|
const groupId = await resolveTargetGroup(actor, undefined);
|
||||||
|
requirePermission(actor, "codes:create", { groupId });
|
||||||
|
}
|
||||||
|
|
||||||
const file = form.get("file");
|
const file = form.get("file");
|
||||||
if (!(file instanceof File)) {
|
if (!(file instanceof File)) {
|
||||||
return jsonError("Es wurde keine Datei mitgeschickt.");
|
return jsonError("Es wurde keine Datei mitgeschickt.");
|
||||||
|
|||||||
+72
-1
@@ -9,10 +9,81 @@
|
|||||||
monospace;
|
monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- Dark Mode
|
||||||
|
|
||||||
|
Umgesetzt über `light-dark()` statt über `dark:`-Klassen.
|
||||||
|
|
||||||
|
Der Grund: Die Oberfläche benutzt an rund 440 Stellen feste Farbklassen wie
|
||||||
|
`bg-slate-50` oder `text-slate-900`. Jede davon um eine `dark:`-Variante zu
|
||||||
|
ergänzen wäre eine riesige, fehleranfällige Fleißarbeit — und jede künftige
|
||||||
|
Zeile müsste daran denken. Stattdessen werden hier die Farbvariablen
|
||||||
|
umdefiniert, die Tailwind ohnehin benutzt: `bg-slate-50` wird zu
|
||||||
|
`background-color: var(--color-slate-50)`, und was diese Variable enthält,
|
||||||
|
entscheidet sich hier.
|
||||||
|
|
||||||
|
Welche Hälfte von `light-dark()` gilt, steuert `color-scheme` — deshalb
|
||||||
|
genügt für den Umschalter ein Attribut am <html>-Element. Ohne Attribut
|
||||||
|
folgt die Anwendung dem Betriebssystem, ganz ohne JavaScript.
|
||||||
|
|
||||||
|
Die dunklen Werte sind keine bloße Spiegelung: Reines Schwarz unter weißer
|
||||||
|
Schrift flimmert. Die Flächen liegen deshalb bei 16–26 % Helligkeit, mit
|
||||||
|
demselben leicht blauen Ton wie die helle Reihe.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* Grautöne. Hell: die Reihe von Tailwind. Dunkel: von dunkel nach hell
|
||||||
|
gedreht, damit `text-slate-900` weiterhin „die kräftigste Schrift“
|
||||||
|
bedeutet und `bg-slate-50` „der ruhigste Grund“. */
|
||||||
|
--color-slate-50: light-dark(oklch(98.4% 0.003 247.858), oklch(16.5% 0.018 265));
|
||||||
|
--color-slate-100: light-dark(oklch(96.8% 0.007 247.896), oklch(21% 0.02 265));
|
||||||
|
--color-slate-200: light-dark(oklch(92.9% 0.013 255.508), oklch(28% 0.022 262));
|
||||||
|
--color-slate-300: light-dark(oklch(86.9% 0.022 252.894), oklch(34% 0.026 260));
|
||||||
|
--color-slate-400: light-dark(oklch(70.4% 0.04 256.788), oklch(58% 0.032 258));
|
||||||
|
--color-slate-500: light-dark(oklch(55.4% 0.046 257.417), oklch(68% 0.03 257));
|
||||||
|
--color-slate-600: light-dark(oklch(44.6% 0.043 257.281), oklch(77% 0.025 257));
|
||||||
|
--color-slate-700: light-dark(oklch(37.2% 0.044 257.287), oklch(85% 0.02 257));
|
||||||
|
--color-slate-800: light-dark(oklch(27.9% 0.041 260.031), oklch(91% 0.014 258));
|
||||||
|
--color-slate-900: light-dark(oklch(20.8% 0.042 265.755), oklch(96.5% 0.008 258));
|
||||||
|
--color-slate-950: light-dark(oklch(12.9% 0.042 264.695), oklch(99% 0.003 258));
|
||||||
|
|
||||||
|
/* Flächen. `bg-white` wäre im Dunkeln falsch, `text-white` dagegen richtig
|
||||||
|
(es steht auf farbigem Grund) — beide teilen sich aber dieselbe Variable.
|
||||||
|
Deshalb tragen Flächen einen eigenen Namen: `bg-surface`. */
|
||||||
|
--color-surface: light-dark(#ffffff, oklch(22.5% 0.02 264));
|
||||||
|
|
||||||
|
/* Akzent auf dunklem Grund. Die Reihe selbst bleibt, wie das
|
||||||
|
Erscheinungsbild sie setzt — hier wird nur eine andere Stufe gewählt:
|
||||||
|
Fließtext braucht im Dunkeln eine hellere, Flächen eine dunklere. */
|
||||||
|
--color-accent-text: light-dark(
|
||||||
|
var(--color-indigo-600),
|
||||||
|
var(--color-indigo-400)
|
||||||
|
);
|
||||||
|
--color-accent-soft: light-dark(
|
||||||
|
var(--color-indigo-50),
|
||||||
|
color-mix(in oklab, var(--color-indigo-500) 22%, oklch(22.5% 0.02 264))
|
||||||
|
);
|
||||||
|
--color-accent-soft-fg: light-dark(
|
||||||
|
var(--color-indigo-700),
|
||||||
|
var(--color-indigo-300)
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Immer dunkel, unabhängig vom Thema: die Fläche hinter einem Dialog und
|
||||||
|
der Grund einer Videovorschau. */
|
||||||
|
--color-scrim: oklch(20.8% 0.042 265.755);
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--color-slate-50);
|
background-color: var(--color-slate-50);
|
||||||
color: var(--color-slate-900);
|
color: var(--color-slate-900);
|
||||||
@@ -22,7 +93,7 @@ body {
|
|||||||
|
|
||||||
/* Sichtbarer, einheitlicher Fokusring für Tastaturbedienung. */
|
/* Sichtbarer, einheitlicher Fokusring für Tastaturbedienung. */
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--color-indigo-600);
|
outline: 2px solid var(--color-accent-text);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+52
-7
@@ -1,28 +1,73 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { THEME_INIT_SCRIPT } from "@/components/theme-toggle";
|
||||||
|
import { accentCss } from "@/lib/branding-colors";
|
||||||
|
import { getBranding } from "@/lib/branding";
|
||||||
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
// Name, Farbe und Favicon kommen aus der Datenbank — nichts davon darf
|
||||||
|
// vorgerendert werden.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const branding = await getBranding();
|
||||||
|
|
||||||
|
return {
|
||||||
title: {
|
title: {
|
||||||
default: "QR-Code-Generator",
|
default: branding.appName,
|
||||||
template: "%s · QR-Code-Generator",
|
template: `%s · ${branding.appName}`,
|
||||||
},
|
},
|
||||||
description:
|
description:
|
||||||
"QR-Codes erstellen: statisch oder dynamisch, mit eigenen Farben, frei wählbarer Größe und Download als PNG oder SVG.",
|
"QR-Codes erstellen: statisch oder dynamisch, mit eigenen Farben, frei wählbarer Größe und Download als PNG oder SVG.",
|
||||||
};
|
// Ohne eigenes Favicon bleibt es bei src/app/favicon.ico.
|
||||||
|
...(branding.faviconUploadId
|
||||||
|
? {
|
||||||
|
icons: {
|
||||||
|
icon: {
|
||||||
|
url: `/api/uploads/${branding.faviconUploadId}`,
|
||||||
|
type: branding.faviconMimeType ?? undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wurzel-Layout — bewusst nur `html` und `body`.
|
* Wurzel-Layout — bewusst nur `html`, `body` und die Akzentfarbe.
|
||||||
*
|
*
|
||||||
* Kopfzeile, Navigation und Fußzeile der Anwendung stehen eine Ebene tiefer in
|
* Kopfzeile, Navigation und Fußzeile der Anwendung stehen eine Ebene tiefer in
|
||||||
* `(app)/layout.tsx`. Denn die öffentlichen Landingpages unter `/s/[slug]`
|
* `(app)/layout.tsx`. Denn die öffentlichen Landingpages unter `/s/[slug]`
|
||||||
* bekommen ein Feld voller Fremder zu sehen: dort haben „Verwaltung“ und
|
* bekommen ein Feld voller Fremder zu sehen: dort haben „Verwaltung“ und
|
||||||
* „Meine Codes“ nichts zu suchen.
|
* „Meine Codes“ nichts zu suchen.
|
||||||
|
*
|
||||||
|
* Die Farbe gehört dagegen hierher, damit auch die Landingpages sie tragen.
|
||||||
*/
|
*/
|
||||||
export default function RootLayout({ children }: LayoutProps<"/">) {
|
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
|
const branding = await getBranding();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="de" className="h-full">
|
<html lang="de" className="h-full">
|
||||||
<body className="flex min-h-full flex-col">{children}</body>
|
<body className="flex min-h-full flex-col">
|
||||||
|
{/*
|
||||||
|
Setzt eine ausdrücklich gewählte Fassung, bevor der Browser zeichnet.
|
||||||
|
Wer der Systemeinstellung folgt, braucht das nicht — das erledigt
|
||||||
|
`light-dark()` im CSS von allein.
|
||||||
|
*/}
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
|
||||||
|
{/*
|
||||||
|
Überschreibt die Farbvariablen, die Tailwind ohnehin benutzt — damit
|
||||||
|
wirkt die Akzentfarbe an jeder Stelle der Oberfläche, ohne dass eine
|
||||||
|
einzige Klasse angefasst werden müsste. Der Inhalt entsteht
|
||||||
|
ausschließlich aus geprüften Zahlen; eine ungültige Farbe fällt auf
|
||||||
|
die Voreinstellung zurück.
|
||||||
|
*/}
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{ __html: accentCss(branding.accentColor) }}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import { prisma } from "@/lib/db";
|
|||||||
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
|
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
|
||||||
import { getQrType } from "@/lib/qr/registry";
|
import { getQrType } from "@/lib/qr/registry";
|
||||||
import { isValidSlug } from "@/lib/slug";
|
import { isValidSlug } from "@/lib/slug";
|
||||||
|
import { findCodeBySlug } from "@/lib/slug-resolve";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type Context = { params: Promise<{ slug: string }> };
|
// Ein Kurz-Link darf „/" enthalten (z. B. kunde-a/sommer), deshalb eine
|
||||||
|
// Sammelroute: Next liefert die Abschnitte dann als Liste.
|
||||||
|
type Context = { params: Promise<{ slug: string[] }> };
|
||||||
|
|
||||||
function notFound(message: string): Response {
|
function notFound(message: string): Response {
|
||||||
return new Response(
|
return new Response(
|
||||||
@@ -44,7 +47,8 @@ export async function GET(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
context: Context,
|
context: Context,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const { slug } = await context.params;
|
const abschnitte = (await context.params).slug;
|
||||||
|
const slug = (abschnitte ?? []).join("/");
|
||||||
|
|
||||||
if (!isValidSlug(slug)) {
|
if (!isValidSlug(slug)) {
|
||||||
return notFound("Diese Kurz-Adresse ist ungültig.");
|
return notFound("Diese Kurz-Adresse ist ungültig.");
|
||||||
@@ -65,9 +69,8 @@ export async function GET(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const code = await prisma.qrCode.findUnique({
|
const gefunden = await findCodeBySlug(domain.id, slug);
|
||||||
where: { domainId_slug: { domainId: domain.id, slug } },
|
const code = gefunden?.code;
|
||||||
});
|
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return notFound(
|
return notFound(
|
||||||
@@ -85,7 +88,7 @@ export async function GET(
|
|||||||
// Kein fremdes Ziel — die Seite liefern wir selbst aus. Bewusst relativ,
|
// Kein fremdes Ziel — die Seite liefern wir selbst aus. Bewusst relativ,
|
||||||
// damit der Aufruf auf der Adresse bleibt, über die er hereinkam (auch
|
// damit der Aufruf auf der Adresse bleibt, über die er hereinkam (auch
|
||||||
// wenn das eine nicht registrierte ist, etwa eine IP im LAN).
|
// wenn das eine nicht registrierte ist, etwa eine IP im LAN).
|
||||||
target = `/s/${slug}`;
|
target = `/s/${code.slug}`;
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
// Der User-Agent geht mit: Typen wie der App-Link entscheiden daran,
|
// Der User-Agent geht mit: Typen wie der App-Link entscheiden daran,
|
||||||
@@ -3,14 +3,15 @@ import { headers } from "next/headers";
|
|||||||
|
|
||||||
import { qrLandings } from "@/components/landing";
|
import { qrLandings } from "@/components/landing";
|
||||||
import { LandingNotice } from "@/components/landing/shell";
|
import { LandingNotice } from "@/components/landing/shell";
|
||||||
import { prisma } from "@/lib/db";
|
|
||||||
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
|
import { ensureDefaultDomain, resolveRequestDomain } from "@/lib/domains";
|
||||||
import { getQrType } from "@/lib/qr/registry";
|
import { getQrType } from "@/lib/qr/registry";
|
||||||
import { isValidSlug } from "@/lib/slug";
|
import { isValidSlug } from "@/lib/slug";
|
||||||
|
import { findCodeBySlug } from "@/lib/slug-resolve";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type PageProps = { params: Promise<{ slug: string }> };
|
// Sammelroute, weil ein Kurz-Link „/" enthalten darf.
|
||||||
|
type PageProps = { params: Promise<{ slug: string[] }> };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Öffentliche Landingpage eines dynamischen Codes.
|
* Öffentliche Landingpage eines dynamischen Codes.
|
||||||
@@ -21,16 +22,15 @@ type PageProps = { params: Promise<{ slug: string }> };
|
|||||||
* Der Scan-Zähler wird bewusst **nicht** hier erhöht, sondern in `/r/[slug]`.
|
* Der Scan-Zähler wird bewusst **nicht** hier erhöht, sondern in `/r/[slug]`.
|
||||||
* Sonst zählte jedes Neuladen dieser Seite als weiterer Scan.
|
* Sonst zählte jedes Neuladen dieser Seite als weiterer Scan.
|
||||||
*/
|
*/
|
||||||
async function loadCode(slug: string) {
|
async function loadCode(abschnitte: string[] | undefined) {
|
||||||
|
const slug = (abschnitte ?? []).join("/");
|
||||||
if (!isValidSlug(slug)) return null;
|
if (!isValidSlug(slug)) return null;
|
||||||
|
|
||||||
const matched = await resolveRequestDomain(await headers());
|
const matched = await resolveRequestDomain(await headers());
|
||||||
const domain = matched ?? { ...(await ensureDefaultDomain()), isActive: true };
|
const domain = matched ?? { ...(await ensureDefaultDomain()), isActive: true };
|
||||||
if (!domain.isActive) return null;
|
if (!domain.isActive) return null;
|
||||||
|
|
||||||
return prisma.qrCode.findUnique({
|
return (await findCodeBySlug(domain.id, slug))?.code ?? null;
|
||||||
where: { domainId_slug: { domainId: domain.id, slug } },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
@@ -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}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardTitle,
|
||||||
|
Field,
|
||||||
|
Notice,
|
||||||
|
TextInput,
|
||||||
|
} from "@/components/ui";
|
||||||
|
import { buildRamp, isHexColor } from "@/lib/branding-colors";
|
||||||
|
import { UPLOAD_KINDS, formatBytes } from "@/lib/uploads.shared";
|
||||||
|
|
||||||
|
export type BrandingDto = {
|
||||||
|
appName: string;
|
||||||
|
footerText: string;
|
||||||
|
accentColor: string;
|
||||||
|
logoUploadId: string | null;
|
||||||
|
faviconUploadId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Vorschläge, damit man nicht mit einem Farbwähler anfangen muss. */
|
||||||
|
const FARBEN = [
|
||||||
|
{ label: "Indigo", value: "#4F46E5" },
|
||||||
|
{ label: "Blau", value: "#1D4ED8" },
|
||||||
|
{ label: "Türkis", value: "#0D9488" },
|
||||||
|
{ label: "Grün", value: "#15803D" },
|
||||||
|
{ label: "Bernstein", value: "#B45309" },
|
||||||
|
{ label: "Rot", value: "#DC2626" },
|
||||||
|
{ label: "Pink", value: "#DB2777" },
|
||||||
|
{ label: "Violett", value: "#7C3AED" },
|
||||||
|
{ label: "Schiefer", value: "#475569" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type BildFeld = "logoUploadId" | "faviconUploadId";
|
||||||
|
|
||||||
|
export function BrandingAdmin({ initial }: { initial: BrandingDto }) {
|
||||||
|
const [wert, setWert] = useState<BrandingDto>(initial);
|
||||||
|
const [gespeichert, setGespeichert] = useState<BrandingDto>(initial);
|
||||||
|
const [fehler, setFehler] = useState<string | null>(null);
|
||||||
|
const [hinweis, setHinweis] = useState<string | null>(null);
|
||||||
|
const [laeuft, setLaeuft] = useState(false);
|
||||||
|
|
||||||
|
const logoInput = useRef<HTMLInputElement>(null);
|
||||||
|
const faviconInput = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const geaendert = JSON.stringify(wert) !== JSON.stringify(gespeichert);
|
||||||
|
const farbeGueltig = isHexColor(wert.accentColor);
|
||||||
|
|
||||||
|
async function hochladen(datei: File, kind: "logo" | "favicon", feld: BildFeld) {
|
||||||
|
setFehler(null);
|
||||||
|
setLaeuft(true);
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("kind", kind);
|
||||||
|
form.set("file", datei);
|
||||||
|
const antwort = await fetch("/api/uploads", { method: "POST", body: form });
|
||||||
|
const rumpf = await antwort.json().catch(() => null);
|
||||||
|
if (!antwort.ok) {
|
||||||
|
throw new Error(rumpf?.error ?? "Die Datei konnte nicht abgelegt werden.");
|
||||||
|
}
|
||||||
|
setWert((alt) => ({ ...alt, [feld]: rumpf.upload.id }));
|
||||||
|
setHinweis(null);
|
||||||
|
} catch (error) {
|
||||||
|
setFehler(error instanceof Error ? error.message : String(error));
|
||||||
|
} finally {
|
||||||
|
setLaeuft(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function speichern() {
|
||||||
|
setFehler(null);
|
||||||
|
setHinweis(null);
|
||||||
|
setLaeuft(true);
|
||||||
|
try {
|
||||||
|
const antwort = await fetch("/api/admin/branding", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(wert),
|
||||||
|
});
|
||||||
|
const rumpf = await antwort.json().catch(() => null);
|
||||||
|
if (!antwort.ok) {
|
||||||
|
throw new Error(rumpf?.error ?? "Speichern nicht möglich.");
|
||||||
|
}
|
||||||
|
setGespeichert(rumpf.branding);
|
||||||
|
setWert(rumpf.branding);
|
||||||
|
setHinweis(
|
||||||
|
"Gespeichert. Die Kopfzeile zeigt es beim nächsten Seitenwechsel.",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setFehler(error instanceof Error ? error.message : String(error));
|
||||||
|
} finally {
|
||||||
|
setLaeuft(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ramp = buildRamp(farbeGueltig ? wert.accentColor : "#4F46E5");
|
||||||
|
const farbe = (stop: number) =>
|
||||||
|
ramp.find((eintrag) => eintrag.stop === stop)?.value ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{fehler ? <Notice tone="error">{fehler}</Notice> : null}
|
||||||
|
{hinweis ? <Notice tone="success">{hinweis}</Notice> : null}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardTitle hint="Steht in der Kopfzeile, im Titel des Browser-Tabs und in der Fußzeile.">
|
||||||
|
Name
|
||||||
|
</CardTitle>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field label="Name der Anwendung" htmlFor="branding-name">
|
||||||
|
<TextInput
|
||||||
|
id="branding-name"
|
||||||
|
value={wert.appName}
|
||||||
|
maxLength={60}
|
||||||
|
disabled={laeuft}
|
||||||
|
onChange={(event) =>
|
||||||
|
setWert({ ...wert, appName: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Zusatz in der Fußzeile"
|
||||||
|
htmlFor="branding-footer"
|
||||||
|
hint="Steht hinter dem Namen. Leer lassen, wenn nur der Name erscheinen soll."
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
id="branding-footer"
|
||||||
|
value={wert.footerText}
|
||||||
|
maxLength={120}
|
||||||
|
disabled={laeuft}
|
||||||
|
onChange={(event) =>
|
||||||
|
setWert({ ...wert, footerText: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardTitle hint="Übernommen werden Farbton und Sättigung. Die Helligkeitsstufen bleiben, wie sie sind — sonst würde weiße Schrift auf hellem Grund unlesbar.">
|
||||||
|
Akzentfarbe
|
||||||
|
</CardTitle>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{FARBEN.map((eintrag) => (
|
||||||
|
<button
|
||||||
|
key={eintrag.value}
|
||||||
|
type="button"
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => setWert({ ...wert, accentColor: eintrag.value })}
|
||||||
|
aria-label={eintrag.label}
|
||||||
|
aria-pressed={
|
||||||
|
wert.accentColor.toUpperCase() === eintrag.value.toUpperCase()
|
||||||
|
}
|
||||||
|
title={eintrag.label}
|
||||||
|
className={`size-9 rounded-full border-2 transition-transform hover:scale-110 ${
|
||||||
|
wert.accentColor.toUpperCase() === eintrag.value.toUpperCase()
|
||||||
|
? "border-slate-900"
|
||||||
|
: "border-transparent"
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: eintrag.value }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-end gap-4">
|
||||||
|
<Field label="Eigener Wert" htmlFor="branding-accent">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
aria-label="Farbe wählen"
|
||||||
|
value={farbeGueltig ? wert.accentColor : "#4F46E5"}
|
||||||
|
disabled={laeuft}
|
||||||
|
onChange={(event) =>
|
||||||
|
setWert({ ...wert, accentColor: event.target.value.toUpperCase() })
|
||||||
|
}
|
||||||
|
className="size-10 cursor-pointer rounded-lg border border-slate-300 bg-surface p-1"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
id="branding-accent"
|
||||||
|
value={wert.accentColor}
|
||||||
|
maxLength={7}
|
||||||
|
spellCheck={false}
|
||||||
|
disabled={laeuft}
|
||||||
|
invalid={!farbeGueltig}
|
||||||
|
className="w-32 font-mono"
|
||||||
|
onChange={(event) =>
|
||||||
|
setWert({ ...wert, accentColor: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="mb-1.5 block text-sm font-medium text-slate-700">
|
||||||
|
So sieht die Abstufung aus
|
||||||
|
</span>
|
||||||
|
<div className="flex overflow-hidden rounded-lg border border-slate-200">
|
||||||
|
{ramp.map((eintrag) => (
|
||||||
|
<span
|
||||||
|
key={eintrag.stop}
|
||||||
|
title={String(eintrag.stop)}
|
||||||
|
className="size-7"
|
||||||
|
style={{ backgroundColor: eintrag.value }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!farbeGueltig ? (
|
||||||
|
<p role="alert" className="mt-2 text-sm text-red-600">
|
||||||
|
Bitte einen Hex-Wert wie #4F46E5 angeben.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Zeigt, was die Farbe an den auffälligsten Stellen bewirkt. */}
|
||||||
|
<div className="mt-5 rounded-xl border border-slate-200 p-4">
|
||||||
|
<span className="mb-3 block text-xs font-semibold tracking-wide text-slate-500 uppercase">
|
||||||
|
Vorschau
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span
|
||||||
|
className="flex size-9 items-center justify-center rounded-lg text-white"
|
||||||
|
style={{ backgroundColor: farbe(600) }}
|
||||||
|
>
|
||||||
|
<Icon name="qr" className="size-5" />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="rounded-lg px-4 py-2.5 text-sm font-medium text-white"
|
||||||
|
style={{ backgroundColor: farbe(600) }}
|
||||||
|
>
|
||||||
|
Dynamischen Code speichern
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="rounded-full px-2 py-0.5 text-xs font-medium"
|
||||||
|
style={{ backgroundColor: farbe(100), color: farbe(700) }}
|
||||||
|
>
|
||||||
|
Abzeichen
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium" style={{ color: farbe(600) }}>
|
||||||
|
Ein Link
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardTitle hint="Ohne eigenes Logo erscheint das Zeichen der Anwendung samt Name.">
|
||||||
|
Logo
|
||||||
|
</CardTitle>
|
||||||
|
<BildFeldZeile
|
||||||
|
vorhanden={wert.logoUploadId}
|
||||||
|
beschreibung={`PNG, JPEG, WebP oder AVIF, bis ${formatBytes(2 * 1024 * 1024)}. Wird auf 36 px Höhe gezeigt.`}
|
||||||
|
accept={UPLOAD_KINDS.logo.accept}
|
||||||
|
inputRef={logoInput}
|
||||||
|
laeuft={laeuft}
|
||||||
|
hintergrund="bg-surface"
|
||||||
|
onWaehlen={(datei) => hochladen(datei, "logo", "logoUploadId")}
|
||||||
|
onEntfernen={() => setWert({ ...wert, logoUploadId: null })}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardTitle hint="Das kleine Bild im Browser-Tab und in den Lesezeichen.">
|
||||||
|
Favicon
|
||||||
|
</CardTitle>
|
||||||
|
<BildFeldZeile
|
||||||
|
vorhanden={wert.faviconUploadId}
|
||||||
|
beschreibung={`PNG, ICO oder WebP, bis ${formatBytes(512 * 1024)}. Am besten quadratisch, mindestens 32 × 32 px.`}
|
||||||
|
accept={UPLOAD_KINDS.favicon.accept}
|
||||||
|
inputRef={faviconInput}
|
||||||
|
laeuft={laeuft}
|
||||||
|
hintergrund="qr-canvas"
|
||||||
|
onWaehlen={(datei) => hochladen(datei, "favicon", "faviconUploadId")}
|
||||||
|
onEntfernen={() => setWert({ ...wert, faviconUploadId: null })}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button onClick={() => void speichern()} disabled={laeuft || !geaendert || !farbeGueltig}>
|
||||||
|
{laeuft ? "Speichert …" : "Speichern"}
|
||||||
|
</Button>
|
||||||
|
{geaendert ? (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => {
|
||||||
|
setWert(gespeichert);
|
||||||
|
setFehler(null);
|
||||||
|
setHinweis(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Verwerfen
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-slate-500">Keine Änderungen offen.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BildFeldZeile({
|
||||||
|
vorhanden,
|
||||||
|
beschreibung,
|
||||||
|
accept,
|
||||||
|
inputRef,
|
||||||
|
laeuft,
|
||||||
|
hintergrund,
|
||||||
|
onWaehlen,
|
||||||
|
onEntfernen,
|
||||||
|
}: {
|
||||||
|
vorhanden: string | null;
|
||||||
|
beschreibung: string;
|
||||||
|
accept: string;
|
||||||
|
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||||
|
laeuft: boolean;
|
||||||
|
hintergrund: string;
|
||||||
|
onWaehlen: (datei: File) => void;
|
||||||
|
onEntfernen: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<span
|
||||||
|
className={`flex h-16 w-24 shrink-0 items-center justify-center rounded-lg border border-slate-200 p-2 ${hintergrund}`}
|
||||||
|
>
|
||||||
|
{vorhanden ? (
|
||||||
|
/* eslint-disable-next-line @next/next/no-img-element -- aus der
|
||||||
|
Datenbank ausgeliefert */
|
||||||
|
<img
|
||||||
|
src={`/api/uploads/${vorhanden}`}
|
||||||
|
alt="Vorschau"
|
||||||
|
className="max-h-full max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-slate-400">keins</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm text-slate-600">{beschreibung}</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{vorhanden ? "Ersetzen" : "Datei wählen"}
|
||||||
|
</Button>
|
||||||
|
{vorhanden ? (
|
||||||
|
<Button variant="ghost" size="sm" disabled={laeuft} onClick={onEntfernen}>
|
||||||
|
Entfernen
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
const datei = event.target.files?.[0];
|
||||||
|
// Zurücksetzen, damit dieselbe Datei erneut gewählt werden kann.
|
||||||
|
event.target.value = "";
|
||||||
|
if (datei) onWaehlen(datei);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+188
-25
@@ -19,6 +19,7 @@ import {
|
|||||||
import { ApiError, apiJson } from "@/lib/api-client";
|
import { ApiError, apiJson } from "@/lib/api-client";
|
||||||
import type { QrCodeDto } from "@/lib/dto";
|
import type { QrCodeDto } from "@/lib/dto";
|
||||||
import { qrTypes } from "@/lib/qr/registry";
|
import { qrTypes } from "@/lib/qr/registry";
|
||||||
|
import { describeSlugProblem } from "@/lib/slug";
|
||||||
import type { QrFormData } from "@/lib/qr/type-module";
|
import type { QrFormData } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
const dateFormat = new Intl.DateTimeFormat("de-DE", {
|
const dateFormat = new Intl.DateTimeFormat("de-DE", {
|
||||||
@@ -65,11 +66,19 @@ function displayName(code: QrCodeDto): string {
|
|||||||
return code.label?.trim() || code.summary;
|
return code.label?.trim() || code.summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CodesList() {
|
export function CodesList({
|
||||||
|
/** Darf der Kurz-Link geändert werden? Sonst erscheinen die Felder nicht. */
|
||||||
|
maySetSlug = true,
|
||||||
|
}: {
|
||||||
|
maySetSlug?: boolean;
|
||||||
|
}) {
|
||||||
const [codes, setCodes] = useState<QrCodeDto[] | null>(null);
|
const [codes, setCodes] = useState<QrCodeDto[] | null>(null);
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [editing, setEditing] = useState<QrCodeDto | null>(null);
|
const [editing, setEditing] = useState<QrCodeDto | null>(null);
|
||||||
|
const [editSlug, setEditSlug] = useState("");
|
||||||
|
/** Welche früheren Adressen gelten sollen — was hier fehlt, wird freigegeben. */
|
||||||
|
const [editAliases, setEditAliases] = useState<string[]>([]);
|
||||||
const [editData, setEditData] = useState<QrFormData>({});
|
const [editData, setEditData] = useState<QrFormData>({});
|
||||||
const [editLabel, setEditLabel] = useState("");
|
const [editLabel, setEditLabel] = useState("");
|
||||||
const [editError, setEditError] = useState<string | null>(null);
|
const [editError, setEditError] = useState<string | null>(null);
|
||||||
@@ -124,9 +133,21 @@ export function CodesList() {
|
|||||||
setEditing(code);
|
setEditing(code);
|
||||||
setEditData(code.data);
|
setEditData(code.data);
|
||||||
setEditLabel(code.label ?? "");
|
setEditLabel(code.label ?? "");
|
||||||
|
setEditSlug(code.slug ?? "");
|
||||||
|
setEditAliases(code.aliases);
|
||||||
setEditError(null);
|
setEditError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const slugProblem =
|
||||||
|
editSlug.trim().length > 0 ? describeSlugProblem(editSlug) : null;
|
||||||
|
const slugGeaendert =
|
||||||
|
editing !== null &&
|
||||||
|
editing.slug !== null &&
|
||||||
|
editSlug.trim().toLowerCase() !== editing.slug.toLowerCase();
|
||||||
|
const freigegeben = editing
|
||||||
|
? editing.aliases.filter((alias) => !editAliases.includes(alias))
|
||||||
|
: [];
|
||||||
|
|
||||||
const EditForm = editing ? qrForms[editing.type] : undefined;
|
const EditForm = editing ? qrForms[editing.type] : undefined;
|
||||||
const editValidation = useMemo(
|
const editValidation = useMemo(
|
||||||
() => (editing ? qrTypes[editing.type].validate(editData) : null),
|
() => (editing ? qrTypes[editing.type].validate(editData) : null),
|
||||||
@@ -139,12 +160,29 @@ export function CodesList() {
|
|||||||
setEditError(editValidation.message);
|
setEditError(editValidation.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (slugProblem) {
|
||||||
|
setEditError(slugProblem);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setEditError(null);
|
setEditError(null);
|
||||||
try {
|
try {
|
||||||
await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, {
|
await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
json: { data: editValidation.data, label: editLabel.trim() || null },
|
json: {
|
||||||
|
label: editLabel.trim() || null,
|
||||||
|
// Ein statischer Code ist ein Schnappschuss — Inhalt und Kurz-Link
|
||||||
|
// gehören nicht in die Anfrage.
|
||||||
|
...(editing.mode === "static"
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
data: editValidation.data,
|
||||||
|
// Nur mitschicken, wenn es bearbeitet werden durfte.
|
||||||
|
...(maySetSlug
|
||||||
|
? { slug: editSlug.trim(), aliases: editAliases }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
await load();
|
await load();
|
||||||
@@ -179,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);
|
||||||
@@ -297,7 +336,7 @@ export function CodesList() {
|
|||||||
<div
|
<div
|
||||||
role="group"
|
role="group"
|
||||||
aria-label="Ansicht"
|
aria-label="Ansicht"
|
||||||
className="flex shrink-0 rounded-lg border border-slate-300 bg-white p-0.5"
|
className="flex shrink-0 rounded-lg border border-slate-300 bg-surface p-0.5"
|
||||||
>
|
>
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
@@ -316,7 +355,7 @@ export function CodesList() {
|
|||||||
className={cx(
|
className={cx(
|
||||||
"rounded-md p-2 transition-colors",
|
"rounded-md p-2 transition-colors",
|
||||||
view === value
|
view === value
|
||||||
? "bg-indigo-50 text-indigo-700"
|
? "bg-accent-soft text-accent-soft-fg"
|
||||||
: "text-slate-500 hover:bg-slate-100 hover:text-slate-700",
|
: "text-slate-500 hover:bg-slate-100 hover:text-slate-700",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -374,7 +413,7 @@ export function CodesList() {
|
|||||||
setQuery("");
|
setQuery("");
|
||||||
setGroupFilter("alle");
|
setGroupFilter("alle");
|
||||||
}}
|
}}
|
||||||
className="font-medium text-indigo-600 underline"
|
className="font-medium text-accent-text underline"
|
||||||
>
|
>
|
||||||
Filter zurücksetzen
|
Filter zurücksetzen
|
||||||
</button>
|
</button>
|
||||||
@@ -384,7 +423,7 @@ export function CodesList() {
|
|||||||
|
|
||||||
{/* ------------------------------------------------------ Listenansicht */}
|
{/* ------------------------------------------------------ Listenansicht */}
|
||||||
{view === "list" && visible.length > 0 ? (
|
{view === "list" && visible.length > 0 ? (
|
||||||
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
<div className="rounded-2xl border border-slate-200 bg-surface shadow-sm">
|
||||||
{/* Auf schmalen Schirmen blenden wir Spalten von rechts nach links
|
{/* Auf schmalen Schirmen blenden wir Spalten von rechts nach links
|
||||||
aus, statt die Tabelle seitlich scrollen zu lassen. Was wegfällt,
|
aus, statt die Tabelle seitlich scrollen zu lassen. Was wegfällt,
|
||||||
steht beim Namen (Kurz-URL) oder in der Kartenansicht. */}
|
steht beim Namen (Kurz-URL) oder in der Kartenansicht. */}
|
||||||
@@ -449,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 ? (
|
||||||
|
<span
|
||||||
|
className="font-mono md:hidden"
|
||||||
|
title={code.shortUrl}
|
||||||
|
>
|
||||||
{code.shortUrl.replace(/^https?:\/\/[^/]+/, "")}
|
{code.shortUrl.replace(/^https?:\/\/[^/]+/, "")}
|
||||||
</span>
|
</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>
|
||||||
@@ -461,12 +509,13 @@ 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">
|
||||||
|
{code.shortUrl ? (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<a
|
<a
|
||||||
href={code.shortUrl}
|
href={code.shortUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="truncate font-mono text-xs text-indigo-600 hover:underline"
|
className="truncate font-mono text-xs text-accent-text hover:underline"
|
||||||
title={code.shortUrl}
|
title={code.shortUrl}
|
||||||
>
|
>
|
||||||
{code.shortUrl.replace(/^https?:\/\//, "")}
|
{code.shortUrl.replace(/^https?:\/\//, "")}
|
||||||
@@ -483,6 +532,11 @@ export function CodesList() {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
||||||
@@ -497,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>
|
||||||
)}
|
)}
|
||||||
@@ -512,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
|
||||||
@@ -539,8 +605,8 @@ export function CodesList() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openEdit(code)}
|
onClick={() => openEdit(code)}
|
||||||
title="Ziel bearbeiten"
|
title="Bearbeiten"
|
||||||
aria-label={`Ziel von ${displayName(code)} bearbeiten`}
|
aria-label={`${displayName(code)} bearbeiten`}
|
||||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||||
>
|
>
|
||||||
<Icon name="pencil" className="size-4" />
|
<Icon name="pencil" className="size-4" />
|
||||||
@@ -610,6 +676,7 @@ export function CodesList() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl className="mt-3 space-y-2 text-sm">
|
<dl className="mt-3 space-y-2 text-sm">
|
||||||
|
{code.shortUrl ? (
|
||||||
<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">Kurz-URL:</dt>
|
<dt className="text-slate-500">Kurz-URL:</dt>
|
||||||
<dd className="flex items-center gap-2">
|
<dd className="flex items-center gap-2">
|
||||||
@@ -617,7 +684,7 @@ export function CodesList() {
|
|||||||
href={code.shortUrl}
|
href={code.shortUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="font-mono text-xs break-all text-indigo-600 hover:underline"
|
className="font-mono text-xs break-all text-accent-text hover:underline"
|
||||||
>
|
>
|
||||||
{code.shortUrl}
|
{code.shortUrl}
|
||||||
</a>
|
</a>
|
||||||
@@ -634,6 +701,16 @@ export function CodesList() {
|
|||||||
</button>
|
</button>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</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>
|
||||||
@@ -670,7 +747,7 @@ export function CodesList() {
|
|||||||
{code.type === "feedback" ? (
|
{code.type === "feedback" ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/meine-codes/${code.id}/feedback`}
|
href={`/meine-codes/${code.id}/feedback`}
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-surface px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||||
>
|
>
|
||||||
<Icon name="star" className="size-3.5" />
|
<Icon name="star" className="size-3.5" />
|
||||||
Rückmeldungen
|
Rückmeldungen
|
||||||
@@ -683,18 +760,18 @@ export function CodesList() {
|
|||||||
onClick={() => openEdit(code)}
|
onClick={() => openEdit(code)}
|
||||||
>
|
>
|
||||||
<Icon name="pencil" className="size-3.5" />
|
<Icon name="pencil" className="size-3.5" />
|
||||||
Ziel bearbeiten
|
Bearbeiten
|
||||||
</Button>
|
</Button>
|
||||||
<a
|
<a
|
||||||
href={`/api/codes/${code.id}/qr?format=png&download=1`}
|
href={`/api/codes/${code.id}/qr?format=png&download=1`}
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-surface px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||||
>
|
>
|
||||||
<Icon name="download" className="size-3.5" />
|
<Icon name="download" className="size-3.5" />
|
||||||
PNG
|
PNG
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={`/api/codes/${code.id}/qr?format=svg&download=1`}
|
href={`/api/codes/${code.id}/qr?format=svg&download=1`}
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-surface px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||||
>
|
>
|
||||||
<Icon name="download" className="size-3.5" />
|
<Icon name="download" className="size-3.5" />
|
||||||
SVG
|
SVG
|
||||||
@@ -721,7 +798,7 @@ export function CodesList() {
|
|||||||
{/* ------------------------------------------------------ Bearbeiten */}
|
{/* ------------------------------------------------------ Bearbeiten */}
|
||||||
<Modal
|
<Modal
|
||||||
open={editing !== null}
|
open={editing !== null}
|
||||||
title="Ziel bearbeiten"
|
title="Code bearbeiten"
|
||||||
onClose={() => setEditing(null)}
|
onClose={() => setEditing(null)}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
@@ -742,15 +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">
|
||||||
Der gedruckte QR-Code bleibt unverändert gültig — er zeigt weiterhin
|
{editing.mode === "static"
|
||||||
auf{" "}
|
? "Ein statischer Code trägt seinen Inhalt selbst — änderbar ist deshalb nur der Name, unter dem du ihn hier wiederfindest."
|
||||||
<code className="font-mono text-xs break-all">
|
: "Das Ziel lässt sich jederzeit ändern — der gedruckte QR-Code bleibt gültig, solange der Kurz-Link derselbe ist."}
|
||||||
{editing.shortUrl}
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{EditForm ? (
|
{editing.mode === "static" ? null : EditForm ? (
|
||||||
<EditForm
|
<EditForm
|
||||||
value={editData}
|
value={editData}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
@@ -766,6 +840,95 @@ export function CodesList() {
|
|||||||
</Notice>
|
</Notice>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{maySetSlug && editing.mode !== "static" ? (
|
||||||
|
<>
|
||||||
|
<Field
|
||||||
|
label="Kurz-Link"
|
||||||
|
htmlFor="edit-slug"
|
||||||
|
hint="Kleinbuchstaben, Ziffern, Bindestriche und „/“ als Trenner."
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
id="edit-slug"
|
||||||
|
value={editSlug}
|
||||||
|
maxLength={100}
|
||||||
|
spellCheck={false}
|
||||||
|
invalid={Boolean(slugProblem)}
|
||||||
|
disabled={saving}
|
||||||
|
onChange={(event) => {
|
||||||
|
setEditSlug(event.target.value);
|
||||||
|
setEditError(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{slugProblem ? (
|
||||||
|
<Notice tone="error">{slugProblem}</Notice>
|
||||||
|
) : slugGeaendert ? (
|
||||||
|
<Notice tone="info">
|
||||||
|
Der bisherige Kurz-Link{" "}
|
||||||
|
<code className="font-mono text-xs break-all">{editing.slug}</code>{" "}
|
||||||
|
bleibt gültig — schon gedruckte Codes funktionieren weiter. Unten
|
||||||
|
lässt er sich später freigeben.
|
||||||
|
</Notice>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{editing.aliases.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<span className="mb-1.5 block text-sm font-medium text-slate-700">
|
||||||
|
Frühere Kurz-Links
|
||||||
|
</span>
|
||||||
|
<p className="mb-2 text-sm text-slate-500">
|
||||||
|
Führen weiterhin auf diesen Code. Freigeben nur, wenn damit
|
||||||
|
nichts gedruckt ist — die Adresse ist danach sofort tot.
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{editing.aliases.map((alias) => {
|
||||||
|
const behalten = editAliases.includes(alias);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={alias}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-lg border border-slate-200 px-3 py-2"
|
||||||
|
>
|
||||||
|
<code
|
||||||
|
className={
|
||||||
|
behalten
|
||||||
|
? "min-w-0 font-mono text-xs break-all text-slate-700"
|
||||||
|
: "min-w-0 font-mono text-xs break-all text-slate-400 line-through"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
/r/{alias}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={behalten ? "ghost" : "secondary"}
|
||||||
|
size="sm"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() =>
|
||||||
|
setEditAliases((alt) =>
|
||||||
|
behalten
|
||||||
|
? alt.filter((eintrag) => eintrag !== alias)
|
||||||
|
: [...alt, alias],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{behalten ? "Freigeben" : "Doch behalten"}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{freigegeben.length > 0 ? (
|
||||||
|
<Notice tone="warning">
|
||||||
|
{freigegeben.length === 1
|
||||||
|
? "Eine frühere Adresse wird beim Speichern freigegeben und funktioniert danach nicht mehr."
|
||||||
|
: `${freigegeben.length} frühere Adressen werden beim Speichern freigegeben und funktionieren danach nicht mehr.`}
|
||||||
|
</Notice>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Field label="Name (optional)" htmlFor="edit-label">
|
<Field label="Name (optional)" htmlFor="edit-label">
|
||||||
<TextInput
|
<TextInput
|
||||||
id="edit-label"
|
id="edit-label"
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function ColorField({
|
|||||||
aria-label={`${label} auswählen`}
|
aria-label={`${label} auswählen`}
|
||||||
value={valid ? value : "#000000"}
|
value={valid ? value : "#000000"}
|
||||||
onChange={(event) => onChange(event.target.value.toUpperCase())}
|
onChange={(event) => onChange(event.target.value.toUpperCase())}
|
||||||
className="size-10 shrink-0 cursor-pointer rounded-lg border border-slate-300 bg-white p-1"
|
className="size-10 shrink-0 cursor-pointer rounded-lg border border-slate-300 bg-surface p-1"
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
id={id}
|
id={id}
|
||||||
@@ -95,7 +95,7 @@ export function DesignPanel({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
patch({ fgColor: preset.fgColor, bgColor: preset.bgColor })
|
patch({ fgColor: preset.fgColor, bgColor: preset.bgColor })
|
||||||
}
|
}
|
||||||
className="flex items-center gap-2 rounded-lg border border-slate-300 bg-white py-1 pr-2.5 pl-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
className="flex items-center gap-2 rounded-lg border border-slate-300 bg-surface py-1 pr-2.5 pl-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="size-4 rounded border border-slate-300"
|
className="size-4 rounded border border-slate-300"
|
||||||
@@ -111,7 +111,7 @@ export function DesignPanel({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
patch({ fgColor: design.bgColor, bgColor: design.fgColor })
|
patch({ fgColor: design.bgColor, bgColor: design.fgColor })
|
||||||
}
|
}
|
||||||
className="rounded-lg border border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
className="rounded-lg border border-slate-300 bg-surface px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||||
>
|
>
|
||||||
Farben tauschen
|
Farben tauschen
|
||||||
</button>
|
</button>
|
||||||
@@ -158,7 +158,7 @@ export function DesignPanel({
|
|||||||
if (Number.isNaN(raw)) return;
|
if (Number.isNaN(raw)) return;
|
||||||
patch({ size: Math.min(MAX_SIZE, Math.max(MIN_SIZE, Math.round(raw))) });
|
patch({ size: Math.min(MAX_SIZE, Math.max(MIN_SIZE, Math.round(raw))) });
|
||||||
}}
|
}}
|
||||||
className="w-24 shrink-0 rounded-lg border border-slate-300 bg-white px-2.5 py-2 text-sm tabular-nums shadow-sm"
|
className="w-24 shrink-0 rounded-lg border border-slate-300 bg-surface px-2.5 py-2 text-sm tabular-nums shadow-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -182,8 +182,8 @@ export function DesignPanel({
|
|||||||
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",
|
||||||
design.ecLevel === level
|
design.ecLevel === level
|
||||||
? "border-indigo-600 bg-indigo-50 text-indigo-700"
|
? "border-indigo-600 bg-accent-soft text-accent-soft-fg"
|
||||||
: "border-slate-300 bg-white text-slate-600 hover:bg-slate-50",
|
: "border-slate-300 bg-surface text-slate-600 hover:bg-slate-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{level}
|
{level}
|
||||||
|
|||||||
@@ -214,6 +214,24 @@ const PATHS = {
|
|||||||
<path d="M4 12h16" />
|
<path d="M4 12h16" />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
sun: (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2" />
|
||||||
|
<path d="m5.3 5.3 1.4 1.4M17.3 17.3l1.4 1.4M18.7 5.3l-1.4 1.4M6.7 17.3l-1.4 1.4" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
moon: (
|
||||||
|
<>
|
||||||
|
<path d="M20 14.3A8.5 8.5 0 0 1 9.7 4a8.5 8.5 0 1 0 10.3 10.3Z" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
monitor: (
|
||||||
|
<>
|
||||||
|
<rect x="2.5" y="4" width="19" height="13" rx="2" />
|
||||||
|
<path d="M9 21h6M12 17v4" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
lock: (
|
lock: (
|
||||||
<>
|
<>
|
||||||
<rect x="4.5" y="10.5" width="15" height="10.5" rx="2" />
|
<rect x="4.5" y="10.5" width="15" height="10.5" rx="2" />
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function FeedbackLanding({ data, codeId }: LandingProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<LandingShell title={title}>
|
<LandingShell title={title}>
|
||||||
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
|
<div className="rounded-2xl border border-slate-200 bg-surface p-5 shadow-sm sm:p-6">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend className="text-center text-base font-medium text-slate-900">
|
<legend className="text-center text-base font-medium text-slate-900">
|
||||||
{question}
|
{question}
|
||||||
@@ -118,7 +118,7 @@ export function FeedbackLanding({ data, codeId }: LandingProps) {
|
|||||||
value={kommentar}
|
value={kommentar}
|
||||||
disabled={laeuft}
|
disabled={laeuft}
|
||||||
onChange={(event) => setKommentar(event.target.value)}
|
onChange={(event) => setKommentar(event.target.value)}
|
||||||
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-sm text-slate-900 shadow-sm placeholder:text-slate-400"
|
className="w-full rounded-lg border border-slate-300 bg-surface px-3 py-2.5 text-sm text-slate-900 shadow-sm placeholder:text-slate-400"
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export function ImageLanding({ data }: LandingProps) {
|
|||||||
{images.map((image, index) => (
|
{images.map((image, index) => (
|
||||||
<figure
|
<figure
|
||||||
key={image.fileId}
|
key={image.fileId}
|
||||||
className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm"
|
className="overflow-hidden rounded-2xl border border-slate-200 bg-surface shadow-sm"
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element -- eigene
|
{/* eslint-disable-next-line @next/next/no-img-element -- eigene
|
||||||
Auslieferung ohne bekannte Maße; der Optimierer von Next
|
Auslieferung ohne bekannte Maße; der Optimierer von Next
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function LinksLanding({ data }: LandingProps) {
|
|||||||
href={link.url}
|
href={link.url}
|
||||||
// Fremde Ziele: kein Zugriff auf dieses Fenster.
|
// Fremde Ziele: kein Zugriff auf dieses Fenster.
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white px-5 py-4 text-slate-900 shadow-sm transition-colors hover:border-indigo-300 hover:bg-indigo-50/60"
|
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-surface px-5 py-4 text-slate-900 shadow-sm transition-colors hover:border-indigo-300 hover:bg-accent-soft/60"
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1 font-medium break-words">
|
<span className="min-w-0 flex-1 font-medium break-words">
|
||||||
{link.label}
|
{link.label}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function PdfLanding({ data }: LandingProps) {
|
|||||||
title={title || filename || "PDF-Dokument"}
|
title={title || filename || "PDF-Dokument"}
|
||||||
subtitle={description || null}
|
subtitle={description || null}
|
||||||
>
|
>
|
||||||
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
<div className="rounded-2xl border border-slate-200 bg-surface p-5 shadow-sm">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-red-50 text-red-600">
|
<span className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-red-50 text-red-600">
|
||||||
<Icon name="document" className="size-6" />
|
<Icon name="document" className="size-6" />
|
||||||
@@ -37,7 +37,7 @@ export function PdfLanding({ data }: LandingProps) {
|
|||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={`${href}?download`}
|
href={`${href}?download`}
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-surface px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||||
>
|
>
|
||||||
<Icon name="download" className="size-4" />
|
<Icon name="download" className="size-4" />
|
||||||
Herunterladen
|
Herunterladen
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function SocialLanding({ data }: LandingProps) {
|
|||||||
<a
|
<a
|
||||||
href={profile.url}
|
href={profile.url}
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-3.5 rounded-2xl border border-slate-200 bg-white px-4 py-3.5 text-slate-900 shadow-sm transition-colors hover:border-slate-300 hover:bg-slate-50"
|
className="flex items-center gap-3.5 rounded-2xl border border-slate-200 bg-surface px-4 py-3.5 text-slate-900 shadow-sm transition-colors hover:border-slate-300 hover:bg-slate-50"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function VideoLanding({ data }: LandingProps) {
|
|||||||
<a
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-white px-5 py-4 text-slate-900 shadow-sm transition-colors hover:border-indigo-300 hover:bg-indigo-50/60"
|
className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-surface px-5 py-4 text-slate-900 shadow-sm transition-colors hover:border-indigo-300 hover:bg-accent-soft/60"
|
||||||
>
|
>
|
||||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600">
|
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600">
|
||||||
<Icon name="video" className="size-5" />
|
<Icon name="video" className="size-5" />
|
||||||
|
|||||||
@@ -41,13 +41,13 @@ export function EmbeddedVideo({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setGestartet(true)}
|
onClick={() => setGestartet(true)}
|
||||||
className="group flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-2xl border border-slate-300 bg-slate-900 px-6 text-center shadow-sm transition-colors hover:bg-slate-800"
|
className="group flex aspect-video w-full flex-col items-center justify-center gap-3 rounded-2xl border border-slate-300 bg-scrim px-6 text-center shadow-sm transition-colors hover:brightness-125"
|
||||||
>
|
>
|
||||||
<span className="flex size-16 items-center justify-center rounded-full bg-white/10 text-white transition-colors group-hover:bg-white/20">
|
<span className="flex size-16 items-center justify-center rounded-full bg-white/10 text-white transition-colors group-hover:bg-white/20">
|
||||||
<Icon name="video" className="size-8" />
|
<Icon name="video" className="size-8" />
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium text-white">Video abspielen</span>
|
<span className="font-medium text-white">Video abspielen</span>
|
||||||
<span className="max-w-xs text-xs leading-relaxed text-slate-300">
|
<span className="max-w-xs text-xs leading-relaxed text-white/75">
|
||||||
Beim Abspielen wird eine Verbindung zu {provider} hergestellt. Dabei
|
Beim Abspielen wird eine Verbindung zu {provider} hergestellt. Dabei
|
||||||
werden Daten an den Anbieter übertragen.
|
werden Daten an den Anbieter übertragen.
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function Modal({
|
|||||||
// Klick auf die Hintergrundfläche des Dialogs schließt ihn.
|
// Klick auf die Hintergrundfläche des Dialogs schließt ihn.
|
||||||
if (event.target === ref.current) onClose();
|
if (event.target === ref.current) onClose();
|
||||||
}}
|
}}
|
||||||
className="m-auto w-[calc(100%-2rem)] max-w-lg rounded-2xl border border-slate-200 bg-white p-0 shadow-xl backdrop:bg-slate-900/40"
|
className="m-auto w-[calc(100%-2rem)] max-w-lg rounded-2xl border border-slate-200 bg-surface p-0 shadow-xl backdrop:bg-scrim/50"
|
||||||
>
|
>
|
||||||
<div className="p-5 sm:p-6">
|
<div className="p-5 sm:p-6">
|
||||||
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
|
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
|
|||||||
<img
|
<img
|
||||||
src={`/api/uploads/${image.fileId}`}
|
src={`/api/uploads/${image.fileId}`}
|
||||||
alt=""
|
alt=""
|
||||||
className="size-14 shrink-0 rounded-lg border border-slate-200 bg-white object-cover"
|
className="size-14 shrink-0 rounded-lg border border-slate-200 bg-surface object-cover"
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
<p className="truncate text-sm text-slate-600">
|
<p className="truncate text-sm text-slate-600">
|
||||||
@@ -95,7 +95,7 @@ export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
|
|||||||
disabled={disabled || index === 0}
|
disabled={disabled || index === 0}
|
||||||
onClick={() => move(index, -1)}
|
onClick={() => move(index, -1)}
|
||||||
aria-label={`Bild ${index + 1} nach oben`}
|
aria-label={`Bild ${index + 1} nach oben`}
|
||||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||||
>
|
>
|
||||||
<Icon name="arrowLeft" className="size-4 rotate-90" />
|
<Icon name="arrowLeft" className="size-4 rotate-90" />
|
||||||
</button>
|
</button>
|
||||||
@@ -104,7 +104,7 @@ export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
|
|||||||
disabled={disabled || index === images.length - 1}
|
disabled={disabled || index === images.length - 1}
|
||||||
onClick={() => move(index, 1)}
|
onClick={() => move(index, 1)}
|
||||||
aria-label={`Bild ${index + 1} nach unten`}
|
aria-label={`Bild ${index + 1} nach unten`}
|
||||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||||
>
|
>
|
||||||
<Icon name="arrowLeft" className="size-4 -rotate-90" />
|
<Icon name="arrowLeft" className="size-4 -rotate-90" />
|
||||||
</button>
|
</button>
|
||||||
@@ -115,7 +115,7 @@ export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
|
|||||||
setImages(images.filter((_, i) => i !== index))
|
setImages(images.filter((_, i) => i !== index))
|
||||||
}
|
}
|
||||||
aria-label={`Bild ${index + 1} entfernen`}
|
aria-label={`Bild ${index + 1} entfernen`}
|
||||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:opacity-40"
|
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-red-600 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<Icon name="trash" className="size-4" />
|
<Icon name="trash" className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export function LinksForm({ value, onChange, error, disabled }: QrFormProps) {
|
|||||||
disabled={disabled || index === 0}
|
disabled={disabled || index === 0}
|
||||||
onClick={() => move(index, -1)}
|
onClick={() => move(index, -1)}
|
||||||
aria-label={`Link ${index + 1} nach oben`}
|
aria-label={`Link ${index + 1} nach oben`}
|
||||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||||
>
|
>
|
||||||
<Icon name="arrowLeft" className="size-4 rotate-90" />
|
<Icon name="arrowLeft" className="size-4 rotate-90" />
|
||||||
</button>
|
</button>
|
||||||
@@ -134,7 +134,7 @@ export function LinksForm({ value, onChange, error, disabled }: QrFormProps) {
|
|||||||
disabled={disabled || index === links.length - 1}
|
disabled={disabled || index === links.length - 1}
|
||||||
onClick={() => move(index, 1)}
|
onClick={() => move(index, 1)}
|
||||||
aria-label={`Link ${index + 1} nach unten`}
|
aria-label={`Link ${index + 1} nach unten`}
|
||||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||||
>
|
>
|
||||||
<Icon name="arrowLeft" className="size-4 -rotate-90" />
|
<Icon name="arrowLeft" className="size-4 -rotate-90" />
|
||||||
</button>
|
</button>
|
||||||
@@ -143,7 +143,7 @@ export function LinksForm({ value, onChange, error, disabled }: QrFormProps) {
|
|||||||
disabled={disabled || links.length === 1}
|
disabled={disabled || links.length === 1}
|
||||||
onClick={() => setLinks(links.filter((_, i) => i !== index))}
|
onClick={() => setLinks(links.filter((_, i) => i !== index))}
|
||||||
aria-label={`Link ${index + 1} entfernen`}
|
aria-label={`Link ${index + 1} entfernen`}
|
||||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||||
>
|
>
|
||||||
<Icon name="trash" className="size-4" />
|
<Icon name="trash" className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function PdfForm({ value, onChange, error, disabled }: QrFormProps) {
|
|||||||
onChange({ ...value, fileId: "", filename: "", size: 0 })
|
onChange({ ...value, fileId: "", filename: "", size: 0 })
|
||||||
}
|
}
|
||||||
aria-label="Datei entfernen"
|
aria-label="Datei entfernen"
|
||||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:opacity-40"
|
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-red-600 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<Icon name="trash" className="size-4" />
|
<Icon name="trash" className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export function SocialForm({ value, onChange, error, disabled }: QrFormProps) {
|
|||||||
setProfiles(profiles.filter((_, i) => i !== index))
|
setProfiles(profiles.filter((_, i) => i !== index))
|
||||||
}
|
}
|
||||||
aria-label={`Profil ${index + 1} entfernen`}
|
aria-label={`Profil ${index + 1} entfernen`}
|
||||||
className="mt-1 rounded-md p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
className="mt-1 rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
||||||
>
|
>
|
||||||
<Icon name="trash" className="size-4" />
|
<Icon name="trash" className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function WebsiteForm({ value, onChange, error, disabled }: QrFormProps) {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
onChange({ ...value, url: `https://${url.trim().replace(/^\/+/, "")}` })
|
onChange({ ...value, url: `https://${url.trim().replace(/^\/+/, "")}` })
|
||||||
}
|
}
|
||||||
className="mt-2 rounded-lg border border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
className="mt-2 rounded-lg border border-slate-300 bg-surface px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||||
>
|
>
|
||||||
https:// voranstellen
|
https:// voranstellen
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+105
-21
@@ -22,6 +22,7 @@ import type { QrCodeDto } from "@/lib/dto";
|
|||||||
import { defaultDesign, designSchema, type QrDesign } from "@/lib/qr/design";
|
import { defaultDesign, designSchema, type QrDesign } from "@/lib/qr/design";
|
||||||
import { qrTypes } from "@/lib/qr/registry";
|
import { qrTypes } from "@/lib/qr/registry";
|
||||||
import type { QrFormData, QrTypeId } from "@/lib/qr/type-module";
|
import type { QrFormData, QrTypeId } from "@/lib/qr/type-module";
|
||||||
|
import { describeSlugProblem } from "@/lib/slug";
|
||||||
|
|
||||||
type PreviewState = { dataUrl: string; content: string };
|
type PreviewState = { dataUrl: string; content: string };
|
||||||
|
|
||||||
@@ -42,7 +43,14 @@ function sameData(a: unknown, b: unknown): boolean {
|
|||||||
return stableStringify(a) === stableStringify(b);
|
return stableStringify(a) === stableStringify(b);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
export function QrGenerator({
|
||||||
|
typeId,
|
||||||
|
/** Darf der Kurz-Link selbst vergeben werden? Sonst erscheint das Feld nicht. */
|
||||||
|
maySetSlug = true,
|
||||||
|
}: {
|
||||||
|
typeId: QrTypeId;
|
||||||
|
maySetSlug?: boolean;
|
||||||
|
}) {
|
||||||
const qrType = qrTypes[typeId];
|
const qrType = qrTypes[typeId];
|
||||||
const Form = qrForms[typeId];
|
const Form = qrForms[typeId];
|
||||||
|
|
||||||
@@ -51,6 +59,8 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
|||||||
// Typen ohne statische Variante — etwa der App-Link — starten dynamisch.
|
// Typen ohne statische Variante — etwa der App-Link — starten dynamisch.
|
||||||
const [isDynamic, setIsDynamic] = useState(!qrType.supportsStatic);
|
const [isDynamic, setIsDynamic] = useState(!qrType.supportsStatic);
|
||||||
const [label, setLabel] = useState("");
|
const [label, setLabel] = useState("");
|
||||||
|
/** Selbst gewählter Kurz-Link. Leer bedeutet: einen erzeugen lassen. */
|
||||||
|
const [wunschSlug, setWunschSlug] = useState("");
|
||||||
|
|
||||||
const [saved, setSaved] = useState<QrCodeDto | null>(null);
|
const [saved, setSaved] = useState<QrCodeDto | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -74,11 +84,13 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
|||||||
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;
|
||||||
@@ -164,6 +176,11 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
|||||||
|
|
||||||
/* ----------------------------------------------- Dynamischen Code speichern */
|
/* ----------------------------------------------- Dynamischen Code speichern */
|
||||||
|
|
||||||
|
// Sofortige Rückmeldung, damit man nicht erst beim Speichern erfährt, dass
|
||||||
|
// ein Zeichen nicht erlaubt ist.
|
||||||
|
const slugProblem =
|
||||||
|
wunschSlug.trim().length > 0 ? describeSlugProblem(wunschSlug) : null;
|
||||||
|
|
||||||
const hasUnsavedChanges =
|
const hasUnsavedChanges =
|
||||||
saved !== null &&
|
saved !== null &&
|
||||||
(!sameData(saved.data, validation.ok ? validation.data : data) ||
|
(!sameData(saved.data, validation.ok ? validation.data : data) ||
|
||||||
@@ -171,7 +188,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
|||||||
(saved.label ?? "") !== label.trim());
|
(saved.label ?? "") !== label.trim());
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (!validation.ok || !designValid) return;
|
if (!validation.ok || !designValid || slugProblem) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
try {
|
try {
|
||||||
@@ -179,7 +196,10 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
|||||||
type: typeId,
|
type: typeId,
|
||||||
data: validation.data,
|
data: validation.data,
|
||||||
design,
|
design,
|
||||||
|
mode,
|
||||||
label: label.trim() || undefined,
|
label: label.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}`, {
|
||||||
@@ -200,10 +220,22 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [validation, designValid, typeId, design, label, 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);
|
||||||
@@ -226,7 +258,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="mb-7 flex flex-wrap items-center gap-3">
|
<div className="mb-7 flex flex-wrap items-center gap-3">
|
||||||
<span className="flex size-11 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600">
|
<span className="flex size-11 items-center justify-center rounded-xl bg-accent-soft text-accent-text">
|
||||||
<Icon name={qrType.icon} className="size-6" />
|
<Icon name={qrType.icon} className="size-6" />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
@@ -286,12 +318,12 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
|||||||
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
|
||||||
? "border-indigo-600 bg-indigo-50/60"
|
? "border-indigo-600 bg-accent-soft/60"
|
||||||
: "border-slate-300 bg-white hover:bg-slate-50",
|
: "border-slate-300 bg-surface hover:bg-slate-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2 font-medium text-slate-900">
|
<span className="flex items-center gap-2 font-medium text-slate-900">
|
||||||
@@ -310,12 +342,12 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
|||||||
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
|
||||||
? "border-indigo-600 bg-indigo-50/60"
|
? "border-indigo-600 bg-accent-soft/60"
|
||||||
: "border-slate-300 bg-white hover:bg-slate-50",
|
: "border-slate-300 bg-surface hover:bg-slate-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2 font-medium text-slate-900">
|
<span className="flex items-center gap-2 font-medium text-slate-900">
|
||||||
@@ -331,12 +363,15 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
|||||||
</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"
|
||||||
@@ -347,13 +382,63 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
{saved ? (
|
{!isDynamic || saved || !maySetSlug ? null : (
|
||||||
|
<Field
|
||||||
|
label="Kurz-Link (optional)"
|
||||||
|
htmlFor="code-slug"
|
||||||
|
error={slugProblem}
|
||||||
|
hint="Leer lassen, dann wird einer erzeugt. Kleinbuchstaben, Ziffern, Bindestriche und „/“ als Trenner."
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
id="code-slug"
|
||||||
|
value={wunschSlug}
|
||||||
|
maxLength={100}
|
||||||
|
spellCheck={false}
|
||||||
|
invalid={Boolean(slugProblem)}
|
||||||
|
placeholder="z. B. sommeraktion oder kunde-a/flyer"
|
||||||
|
onChange={(event) => setWunschSlug(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!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
|
||||||
QR-Code zeigt dauerhaft auf diese Kurz-URL:
|
QR-Code zeigt dauerhaft auf diese Kurz-URL:
|
||||||
<span className="mt-2 flex flex-wrap items-center gap-2">
|
<span className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
<code className="rounded bg-white/70 px-2 py-1 font-mono text-xs break-all">
|
<code className="rounded bg-surface/70 px-2 py-1 font-mono text-xs break-all">
|
||||||
{saved.shortUrl}
|
{saved.shortUrl}
|
||||||
</code>
|
</code>
|
||||||
<Button
|
<Button
|
||||||
@@ -383,7 +468,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
|||||||
) : null}
|
) : null}
|
||||||
<Link
|
<Link
|
||||||
href="/meine-codes"
|
href="/meine-codes"
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-indigo-600 hover:text-indigo-700"
|
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“
|
Zu „Meine Codes“
|
||||||
<Icon name="external" className="size-3.5" />
|
<Icon name="external" className="size-3.5" />
|
||||||
@@ -408,7 +493,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving || !ready}
|
disabled={saving || !ready || Boolean(slugProblem)}
|
||||||
>
|
>
|
||||||
{saving ? "Speichert …" : "Dynamischen Code speichern"}
|
{saving ? "Speichert …" : "Dynamischen Code speichern"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -417,7 +502,6 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
|||||||
|
|
||||||
{saveError ? <Notice tone="error">{saveError}</Notice> : null}
|
{saveError ? <Notice tone="error">{saveError}</Notice> : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function QrPreview({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<span className="absolute top-3 right-3 rounded-full bg-white/90 px-2 py-0.5 text-xs font-medium text-slate-500 shadow-sm">
|
<span className="absolute top-3 right-3 rounded-full bg-surface/90 px-2 py-0.5 text-xs font-medium text-slate-500 shadow-sm">
|
||||||
aktualisiert …
|
aktualisiert …
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { Card, CardTitle, Checkbox, Notice } from "@/components/ui";
|
||||||
|
|
||||||
|
export type SettingsDto = {
|
||||||
|
editorsMaySetSlug: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SettingsAdmin({ initial }: { initial: SettingsDto }) {
|
||||||
|
const [wert, setWert] = useState(initial);
|
||||||
|
const [fehler, setFehler] = useState<string | null>(null);
|
||||||
|
const [hinweis, setHinweis] = useState<string | null>(null);
|
||||||
|
const [laeuft, setLaeuft] = useState(false);
|
||||||
|
|
||||||
|
// Bewusst ohne Speichern-Knopf: Ein einzelner Schalter, der erst nach einem
|
||||||
|
// zweiten Klick wirkt, ist eine Stolperfalle.
|
||||||
|
const umschalten = async (aktiv: boolean) => {
|
||||||
|
setLaeuft(true);
|
||||||
|
setFehler(null);
|
||||||
|
setHinweis(null);
|
||||||
|
const vorher = wert;
|
||||||
|
setWert({ ...wert, editorsMaySetSlug: aktiv });
|
||||||
|
try {
|
||||||
|
const antwort = await fetch("/api/admin/settings", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ editorsMaySetSlug: aktiv }),
|
||||||
|
});
|
||||||
|
const rumpf = await antwort.json().catch(() => null);
|
||||||
|
if (!antwort.ok) {
|
||||||
|
throw new Error(rumpf?.error ?? "Speichern nicht möglich.");
|
||||||
|
}
|
||||||
|
setWert(rumpf.settings);
|
||||||
|
setHinweis(
|
||||||
|
aktiv
|
||||||
|
? "Bearbeiter dürfen Kurz-Links jetzt selbst vergeben und ändern."
|
||||||
|
: "Kurz-Links vergeben und ändern dürfen jetzt nur noch Administratoren.",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setWert(vorher);
|
||||||
|
setFehler(error instanceof Error ? error.message : String(error));
|
||||||
|
} finally {
|
||||||
|
setLaeuft(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{fehler ? <Notice tone="error">{fehler}</Notice> : null}
|
||||||
|
{hinweis ? <Notice tone="success">{hinweis}</Notice> : null}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardTitle hint="Gilt für alle Gruppen dieser Installation.">
|
||||||
|
Kurz-Links
|
||||||
|
</CardTitle>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
id="editors-may-set-slug"
|
||||||
|
label="Bearbeiter dürfen den Kurz-Link selbst vergeben und ändern"
|
||||||
|
hint="Aus: Nur Administratoren. Bearbeiter legen Codes an, der Kurz-Link wird dabei erzeugt."
|
||||||
|
checked={wert.editorsMaySetSlug}
|
||||||
|
disabled={laeuft}
|
||||||
|
onChange={(event) => void umschalten(event.target.checked)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-2 text-sm text-slate-600">
|
||||||
|
<p>
|
||||||
|
Ein Kurz-Link ist eine öffentliche Adresse und steht auf gedruckten
|
||||||
|
Codes. Wer ihn ändert, kann damit Aushänge und Flyer entwerten —
|
||||||
|
die vorherige Adresse bleibt zwar gültig, lässt sich aber auch
|
||||||
|
wieder freigeben.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Deshalb ist das standardmäßig Administratoren vorbehalten. In einem
|
||||||
|
kleinen Team, in dem alle wissen, was ein Kurz-Link bedeutet, ist
|
||||||
|
die Freigabe unbedenklich.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
import { Icon, type IconName } from "@/components/icons";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Umschalter für das Farbthema.
|
||||||
|
*
|
||||||
|
* Drei Zustände statt zwei: Wer „System“ wählt, folgt weiterhin dem
|
||||||
|
* Betriebssystem — abends dunkel, morgens hell, ohne Zutun. Ein reiner
|
||||||
|
* Ein/Aus-Schalter kann das nicht abbilden, weil er nach dem ersten Klick
|
||||||
|
* für immer eine feste Wahl bedeutet.
|
||||||
|
*
|
||||||
|
* Umgesetzt wird die Wahl allein über das Attribut `data-theme` am
|
||||||
|
* <html>-Element; die Farben stecken in `globals.css`. Ohne Attribut gilt die
|
||||||
|
* Systemeinstellung — deshalb wird bei „System“ nicht etwa „light“ gesetzt,
|
||||||
|
* sondern das Attribut entfernt.
|
||||||
|
*/
|
||||||
|
export const THEME_STORAGE_KEY = "qrgen.theme";
|
||||||
|
|
||||||
|
export type Theme = "system" | "light" | "dark";
|
||||||
|
|
||||||
|
const OPTIONEN: { wert: Theme; icon: IconName; label: string }[] = [
|
||||||
|
{ wert: "system", icon: "monitor", label: "System" },
|
||||||
|
{ wert: "light", icon: "sun", label: "Hell" },
|
||||||
|
{ wert: "dark", icon: "moon", label: "Dunkel" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function applyTheme(theme: Theme): void {
|
||||||
|
if (theme === "system") {
|
||||||
|
delete document.documentElement.dataset.theme;
|
||||||
|
} else {
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Läuft vor dem ersten Zeichnen und verhindert das kurze Aufblitzen der
|
||||||
|
* hellen Fassung bei jemandem, der ausdrücklich „Dunkel“ gewählt hat.
|
||||||
|
*
|
||||||
|
* Bewusst winzig und ohne Abhängigkeiten: Alles, was hier steht, blockiert
|
||||||
|
* den Seitenaufbau. Wer der Systemeinstellung folgt, braucht das Skript gar
|
||||||
|
* nicht — dafür sorgt schon das CSS.
|
||||||
|
*/
|
||||||
|
export const THEME_INIT_SCRIPT = `try{var t=localStorage.getItem(${JSON.stringify(
|
||||||
|
THEME_STORAGE_KEY,
|
||||||
|
)});if(t==="dark"||t==="light")document.documentElement.dataset.theme=t}catch(e){}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Wahl liegt im `localStorage` — also außerhalb von React. Dafür ist
|
||||||
|
* `useSyncExternalStore` gedacht: Es liest den Wert beim Einhängen, ohne dass
|
||||||
|
* der Server-Aufbau davon abweicht, und benachrichtigt alle Stellen, wenn er
|
||||||
|
* sich ändert. Nebenbei zieht ein zweiter Tab automatisch nach.
|
||||||
|
*/
|
||||||
|
const zuhoerer = new Set<() => void>();
|
||||||
|
|
||||||
|
function abonnieren(melden: () => void): () => void {
|
||||||
|
zuhoerer.add(melden);
|
||||||
|
window.addEventListener("storage", melden);
|
||||||
|
return () => {
|
||||||
|
zuhoerer.delete(melden);
|
||||||
|
window.removeEventListener("storage", melden);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function lesen(): Theme {
|
||||||
|
try {
|
||||||
|
const wert = localStorage.getItem(THEME_STORAGE_KEY);
|
||||||
|
return wert === "dark" || wert === "light" ? wert : "system";
|
||||||
|
} catch {
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auf dem Server ist nichts gespeichert — dort gilt die Systemeinstellung. */
|
||||||
|
function aufDemServer(): Theme {
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const theme = useSyncExternalStore(abonnieren, lesen, aufDemServer);
|
||||||
|
|
||||||
|
const waehlen = (wert: Theme) => {
|
||||||
|
applyTheme(wert);
|
||||||
|
try {
|
||||||
|
if (wert === "system") localStorage.removeItem(THEME_STORAGE_KEY);
|
||||||
|
else localStorage.setItem(THEME_STORAGE_KEY, wert);
|
||||||
|
} catch {
|
||||||
|
// Privater Modus ohne Speicher — die Wahl gilt dann bis zum Neuladen.
|
||||||
|
}
|
||||||
|
for (const melden of zuhoerer) melden();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label="Farbthema"
|
||||||
|
className="flex items-center gap-0.5 rounded-lg border border-slate-200 p-0.5"
|
||||||
|
>
|
||||||
|
{OPTIONEN.map((option) => {
|
||||||
|
const aktiv = theme === option.wert;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.wert}
|
||||||
|
type="button"
|
||||||
|
data-theme-option={option.wert}
|
||||||
|
aria-pressed={aktiv}
|
||||||
|
title={`Farbthema: ${option.label}`}
|
||||||
|
aria-label={`Farbthema: ${option.label}`}
|
||||||
|
onClick={() => waehlen(option.wert)}
|
||||||
|
className={
|
||||||
|
aktiv
|
||||||
|
? "rounded-md bg-accent-soft p-1.5 text-accent-soft-fg"
|
||||||
|
: "rounded-md p-1.5 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon name={option.icon} className="size-4" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ function Toggle({
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cx(
|
className={cx(
|
||||||
"inline-block size-5 rounded-full bg-white shadow-sm transition-transform",
|
"inline-block size-5 rounded-full bg-surface shadow-sm transition-transform",
|
||||||
checked ? "translate-x-5.5" : "translate-x-0.5",
|
checked ? "translate-x-5.5" : "translate-x-0.5",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -114,7 +114,7 @@ export function TypeSettings({ initial }: { initial: TypeSetting[] }) {
|
|||||||
className={cx(
|
className={cx(
|
||||||
"flex size-10 shrink-0 items-center justify-center rounded-xl",
|
"flex size-10 shrink-0 items-center justify-center rounded-xl",
|
||||||
entry.enabled && entry.implemented
|
entry.enabled && entry.implemented
|
||||||
? "bg-indigo-50 text-indigo-600"
|
? "bg-accent-soft text-accent-text"
|
||||||
: "bg-slate-100 text-slate-400",
|
: "bg-slate-100 text-slate-400",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function Card({
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={cx(
|
className={cx(
|
||||||
"rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6",
|
"rounded-2xl border border-slate-200 bg-surface p-5 shadow-sm sm:p-6",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -57,7 +57,7 @@ const BUTTON_VARIANTS: Record<NonNullable<ButtonProps["variant"]>, string> = {
|
|||||||
primary:
|
primary:
|
||||||
"bg-indigo-600 text-white hover:bg-indigo-700 disabled:hover:bg-indigo-600",
|
"bg-indigo-600 text-white hover:bg-indigo-700 disabled:hover:bg-indigo-600",
|
||||||
secondary:
|
secondary:
|
||||||
"border border-slate-300 bg-white text-slate-700 hover:bg-slate-50 disabled:hover:bg-white",
|
"border border-slate-300 bg-surface text-slate-700 hover:bg-slate-50 disabled:hover:bg-surface",
|
||||||
ghost: "text-slate-600 hover:bg-slate-100 disabled:hover:bg-transparent",
|
ghost: "text-slate-600 hover:bg-slate-100 disabled:hover:bg-transparent",
|
||||||
danger: "bg-red-600 text-white hover:bg-red-700 disabled:hover:bg-red-600",
|
danger: "bg-red-600 text-white hover:bg-red-700 disabled:hover:bg-red-600",
|
||||||
};
|
};
|
||||||
@@ -124,7 +124,7 @@ export function TextInput({
|
|||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
className={cx(
|
className={cx(
|
||||||
"w-full rounded-lg border bg-white px-3 py-2.5 text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400",
|
"w-full rounded-lg border bg-surface px-3 py-2.5 text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400",
|
||||||
invalid
|
invalid
|
||||||
? "border-red-400 focus-visible:outline-red-500"
|
? "border-red-400 focus-visible:outline-red-500"
|
||||||
: "border-slate-300",
|
: "border-slate-300",
|
||||||
@@ -137,7 +137,7 @@ export function TextInput({
|
|||||||
|
|
||||||
/** Gemeinsame Grundgestaltung aller Eingabefelder. */
|
/** Gemeinsame Grundgestaltung aller Eingabefelder. */
|
||||||
export const CONTROL_BASE =
|
export const CONTROL_BASE =
|
||||||
"w-full rounded-lg border bg-white px-3 py-2.5 text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-500";
|
"w-full rounded-lg border bg-surface px-3 py-2.5 text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-500";
|
||||||
|
|
||||||
export function TextArea({
|
export function TextArea({
|
||||||
invalid,
|
invalid,
|
||||||
@@ -243,7 +243,7 @@ export function Badge({
|
|||||||
neutral: "bg-slate-100 text-slate-600",
|
neutral: "bg-slate-100 text-slate-600",
|
||||||
// Hervorhebung — hieß früher "popular", als sie nur für das Abzeichen
|
// Hervorhebung — hieß früher "popular", als sie nur für das Abzeichen
|
||||||
// „Beliebt" auf der Startseite gedacht war.
|
// „Beliebt" auf der Startseite gedacht war.
|
||||||
accent: "bg-indigo-100 text-indigo-700",
|
accent: "bg-accent-soft text-accent-soft-fg",
|
||||||
soon: "bg-slate-100 text-slate-500",
|
soon: "bg-slate-100 text-slate-500",
|
||||||
success: "bg-emerald-100 text-emerald-700",
|
success: "bg-emerald-100 text-emerald-700",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -96,6 +96,58 @@ export function redirectUri(): string {
|
|||||||
return `${getBaseUrl()}/api/auth/oidc/callback`;
|
return `${getBaseUrl()}/api/auth/oidc/callback`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob der Browser über dieselbe Adresse kommt, auf die `BASE_URL` zeigt.
|
||||||
|
*
|
||||||
|
* Warum das zwingend ist: Der Aufruf setzt einen Cookie mit dem laufenden
|
||||||
|
* Vorgang — und zwar auf dem Host, über den der Browser gerade da ist. Die
|
||||||
|
* Rückkehradresse für den Anbieter kommt dagegen aus `BASE_URL`. Weichen die
|
||||||
|
* beiden voneinander ab, schickt der Anbieter den Browser auf einen anderen
|
||||||
|
* Host, der Cookie geht dabei verloren, und die Anmeldung endet mit
|
||||||
|
* „Der Anmeldevorgang ist abgelaufen“ — einer Meldung, die die eigentliche
|
||||||
|
* Ursache gut versteckt.
|
||||||
|
*
|
||||||
|
* Deshalb hier eine frühe Prüfung mit einer Meldung, die beide Adressen nennt.
|
||||||
|
*/
|
||||||
|
export function assertHostMatchesBaseUrl(headers: Headers): void {
|
||||||
|
const angekommen = (
|
||||||
|
headers.get("x-forwarded-host") ??
|
||||||
|
headers.get("host") ??
|
||||||
|
""
|
||||||
|
)
|
||||||
|
.split(",")[0]
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
if (!angekommen) return;
|
||||||
|
|
||||||
|
let erwartet = "";
|
||||||
|
try {
|
||||||
|
erwartet = new URL(getBaseUrl()).host.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return; // Unbrauchbare BASE_URL — dafür gibt es eigene Meldungen.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (angekommen === erwartet) return;
|
||||||
|
|
||||||
|
// Ins Protokoll gehören beide Adressen — dort liest nur mit, wer den Server
|
||||||
|
// bedient. Auf dem Bildschirm steht bewusst nur die, über die der Aufruf
|
||||||
|
// ohnehin kam: Die Anmeldeseite sieht jeder, der die Adresse kennt, und die
|
||||||
|
// interne Adresse der Installation geht Fremde nichts an.
|
||||||
|
console.error(
|
||||||
|
`OIDC: Host-Abweichung — Aufruf über "${angekommen}", BASE_URL zeigt auf "${erwartet}".`,
|
||||||
|
);
|
||||||
|
|
||||||
|
throw new QrRequestError(
|
||||||
|
`Die Anmeldung über den Anbieter ist für „${angekommen}" nicht eingerichtet. ` +
|
||||||
|
`In der Konfiguration steht eine andere Adresse, deshalb ginge der ` +
|
||||||
|
`Anmeldevorgang auf dem Rückweg verloren. Wer die Anwendung betreibt, ` +
|
||||||
|
`findet den erwarteten Wert unter BASE_URL — und die Einzelheiten im ` +
|
||||||
|
`Protokoll des Containers.`,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type Discovery = {
|
type Discovery = {
|
||||||
issuer: string;
|
issuer: string;
|
||||||
authorization_endpoint: string;
|
authorization_endpoint: string;
|
||||||
|
|||||||
+27
-1
@@ -21,12 +21,30 @@ export type Action =
|
|||||||
| "codes:update"
|
| "codes:update"
|
||||||
/** Codes löschen. */
|
/** Codes löschen. */
|
||||||
| "codes:delete"
|
| "codes:delete"
|
||||||
|
/**
|
||||||
|
* Den Kurz-Link selbst vergeben oder ändern.
|
||||||
|
*
|
||||||
|
* Eigene Aktion und nicht Teil von `codes:update`, weil sie mehr wiegt: Ein
|
||||||
|
* Kurz-Link ist eine öffentliche Adresse. Wer ihn ändert, entwertet im
|
||||||
|
* Zweifel gedruckte Codes; wer ihn vergibt, besetzt eine Adresse, die
|
||||||
|
* jemand anderes gebraucht hätte. Deshalb standardmäßig nur für
|
||||||
|
* Administratoren — freigeben lässt es sich in der Verwaltung.
|
||||||
|
*/
|
||||||
|
| "codes:slug"
|
||||||
/** Mitglieder und Rollen einer Gruppe verwalten. */
|
/** Mitglieder und Rollen einer Gruppe verwalten. */
|
||||||
| "group:manage"
|
| "group:manage"
|
||||||
/** Gruppen, Benutzer und die QR-Typ-Freigaben verwalten. */
|
/** Gruppen, Benutzer und die QR-Typ-Freigaben verwalten. */
|
||||||
| "instance:manage";
|
| "instance:manage";
|
||||||
|
|
||||||
export type Scope = { groupId?: string };
|
export type Scope = {
|
||||||
|
groupId?: string;
|
||||||
|
/**
|
||||||
|
* Die Einstellung aus der Verwaltung, ob Bearbeiter Kurz-Links vergeben
|
||||||
|
* dürfen. Wird von der aufrufenden Stelle mitgegeben, damit die Regeln hier
|
||||||
|
* ohne Datenbankzugriff auskommen und prüfbar bleiben.
|
||||||
|
*/
|
||||||
|
editorsMaySetSlug?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const ROLE_RANK: Record<Role, number> = { VIEWER: 0, EDITOR: 1, ADMIN: 2 };
|
const ROLE_RANK: Record<Role, number> = { VIEWER: 0, EDITOR: 1, ADMIN: 2 };
|
||||||
|
|
||||||
@@ -89,6 +107,12 @@ export function can(actor: Actor, action: Action, scope: Scope = {}): boolean {
|
|||||||
case "codes:update":
|
case "codes:update":
|
||||||
case "codes:delete":
|
case "codes:delete":
|
||||||
return ROLE_RANK[role] >= ROLE_RANK.EDITOR;
|
return ROLE_RANK[role] >= ROLE_RANK.EDITOR;
|
||||||
|
case "codes:slug":
|
||||||
|
// Administratoren der Gruppe immer, Bearbeiter nur nach Freigabe.
|
||||||
|
if (role === "ADMIN") return true;
|
||||||
|
return (
|
||||||
|
ROLE_RANK[role] >= ROLE_RANK.EDITOR && scope.editorsMaySetSlug === true
|
||||||
|
);
|
||||||
case "group:manage":
|
case "group:manage":
|
||||||
return role === "ADMIN";
|
return role === "ADMIN";
|
||||||
default:
|
default:
|
||||||
@@ -104,6 +128,8 @@ const DENIED_MESSAGES: Record<Action, string> = {
|
|||||||
"Zum Ändern von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
|
"Zum Ändern von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
|
||||||
"codes:delete":
|
"codes:delete":
|
||||||
"Zum Löschen von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
|
"Zum Löschen von Codes brauchst du in dieser Gruppe mindestens die Rolle „Bearbeiter“.",
|
||||||
|
"codes:slug":
|
||||||
|
"Den Kurz-Link darf hier nur ein Administrator vergeben oder ändern. Ein Administrator der Installation kann das unter Verwaltung → Einstellungen für Bearbeiter freigeben.",
|
||||||
"group:manage":
|
"group:manage":
|
||||||
"Mitglieder und Rollen darf nur ein Administrator der Gruppe verwalten.",
|
"Mitglieder und Rollen darf nur ein Administrator der Gruppe verwalten.",
|
||||||
"instance:manage":
|
"instance:manage":
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Erzeugt aus einer einzigen Akzentfarbe die volle Abstufung 50–950.
|
||||||
|
*
|
||||||
|
* Warum das reicht, um die ganze Oberfläche umzufärben: Tailwind 4 übersetzt
|
||||||
|
* `bg-indigo-600` zu `background-color: var(--color-indigo-600)`. Wer diese
|
||||||
|
* Variablen zur Laufzeit überschreibt, färbt damit jede der rund 60 Stellen
|
||||||
|
* um — ohne eine einzige Klasse anzufassen.
|
||||||
|
*
|
||||||
|
* Vom gewählten Ton werden nur **Farbton und Sättigung** übernommen, nicht
|
||||||
|
* die Helligkeit. Die Helligkeitskurve stammt aus der Tailwind-Vorlage. Das
|
||||||
|
* ist Absicht: Auf `-600` steht weiße Schrift. Käme die Helligkeit aus der
|
||||||
|
* Eingabe, würde ein helles Gelb die Kopfzeile unlesbar machen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Die Abstufung von Tailwind 4 (indigo) als Vorlage für Helligkeit und Form. */
|
||||||
|
const VORLAGE = [
|
||||||
|
{ stop: 50, l: 96.2, c: 0.018, h: 272.314 },
|
||||||
|
{ stop: 100, l: 93.0, c: 0.034, h: 272.788 },
|
||||||
|
{ stop: 200, l: 87.0, c: 0.065, h: 274.039 },
|
||||||
|
{ stop: 300, l: 78.5, c: 0.115, h: 274.713 },
|
||||||
|
{ stop: 400, l: 67.3, c: 0.182, h: 276.935 },
|
||||||
|
{ stop: 500, l: 58.5, c: 0.233, h: 277.117 },
|
||||||
|
{ stop: 600, l: 51.1, c: 0.262, h: 276.966 },
|
||||||
|
{ stop: 700, l: 45.7, c: 0.24, h: 277.023 },
|
||||||
|
{ stop: 800, l: 39.8, c: 0.195, h: 277.366 },
|
||||||
|
{ stop: 900, l: 35.9, c: 0.144, h: 278.697 },
|
||||||
|
{ stop: 950, l: 25.7, c: 0.09, h: 281.288 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const BEZUG = VORLAGE.find((eintrag) => eintrag.stop === 600)!;
|
||||||
|
|
||||||
|
export const DEFAULT_ACCENT = "#4F46E5";
|
||||||
|
|
||||||
|
export function isHexColor(value: string): boolean {
|
||||||
|
return /^#[0-9a-fA-F]{6}$/.test(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sRGB-Kanal (0–1) in seinen linearen Anteil. */
|
||||||
|
function toLinear(kanal: number): number {
|
||||||
|
return kanal <= 0.04045
|
||||||
|
? kanal / 12.92
|
||||||
|
: Math.pow((kanal + 0.055) / 1.055, 2.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hex nach OKLCH.
|
||||||
|
*
|
||||||
|
* OKLab ist so gebaut, dass gleiche Zahlenabstände auch gleich große
|
||||||
|
* wahrgenommene Unterschiede sind — anders als HSL, wo dasselbe „50 %
|
||||||
|
* Helligkeit" bei Gelb und Blau völlig verschieden aussieht.
|
||||||
|
*/
|
||||||
|
export function hexToOklch(hex: string): { l: number; c: number; h: number } {
|
||||||
|
const wert = hex.trim().replace("#", "");
|
||||||
|
const r = toLinear(Number.parseInt(wert.slice(0, 2), 16) / 255);
|
||||||
|
const g = toLinear(Number.parseInt(wert.slice(2, 4), 16) / 255);
|
||||||
|
const b = toLinear(Number.parseInt(wert.slice(4, 6), 16) / 255);
|
||||||
|
|
||||||
|
const lMs = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
||||||
|
const mMs = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
||||||
|
const sMs = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
||||||
|
|
||||||
|
const l = 0.2104542553 * lMs + 0.793617785 * mMs - 0.0040720468 * sMs;
|
||||||
|
const a = 1.9779984951 * lMs - 2.428592205 * mMs + 0.4505937099 * sMs;
|
||||||
|
const bb = 0.0259040371 * lMs + 0.7827717662 * mMs - 0.808675766 * sMs;
|
||||||
|
|
||||||
|
const c = Math.sqrt(a * a + bb * bb);
|
||||||
|
let h = (Math.atan2(bb, a) * 180) / Math.PI;
|
||||||
|
if (h < 0) h += 360;
|
||||||
|
|
||||||
|
return { l: l * 100, c, h };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ColorRamp = { stop: number; value: string }[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die volle Abstufung zur gewählten Akzentfarbe.
|
||||||
|
*
|
||||||
|
* Die Sättigung wird so skaliert, dass der Ton auf `-600` der Eingabe
|
||||||
|
* entspricht; nach oben begrenzt, damit ein sehr kräftiger Wert die hellen
|
||||||
|
* Stufen nicht in ein grelles Pastell kippt. Browser rechnen Werte außerhalb
|
||||||
|
* des darstellbaren Bereichs selbst zurück.
|
||||||
|
*/
|
||||||
|
export function buildRamp(hex: string): ColorRamp {
|
||||||
|
const gewaehlt = hexToOklch(isHexColor(hex) ? hex : DEFAULT_ACCENT);
|
||||||
|
|
||||||
|
// Ein nahezu grauer Wert soll grau bleiben und nicht auf die Sättigung der
|
||||||
|
// Vorlage hochgezogen werden.
|
||||||
|
const faktor = Math.min(gewaehlt.c / BEZUG.c, 1.25);
|
||||||
|
const drift = (h: number) => (h - BEZUG.h + 540) % 360 - 180;
|
||||||
|
|
||||||
|
return VORLAGE.map(({ stop, l, c, h }) => {
|
||||||
|
const ton = (gewaehlt.h + drift(h) + 360) % 360;
|
||||||
|
const saettigung = Math.max(c * faktor, 0);
|
||||||
|
return {
|
||||||
|
stop,
|
||||||
|
value: `oklch(${l.toFixed(1)}% ${saettigung.toFixed(4)} ${ton.toFixed(3)})`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Abstufung als CSS, fertig zum Einsetzen in einen `<style>`-Block.
|
||||||
|
*
|
||||||
|
* Überschrieben wird bewusst die Indigo-Reihe selbst — so wirkt die Farbe
|
||||||
|
* überall dort, wo die Oberfläche sie schon benutzt.
|
||||||
|
*/
|
||||||
|
export function accentCss(hex: string): string {
|
||||||
|
const zeilen = buildRamp(hex)
|
||||||
|
.map(({ stop, value }) => `--color-indigo-${stop}:${value};`)
|
||||||
|
.join("");
|
||||||
|
return `:root{${zeilen}}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { DEFAULT_ACCENT, isHexColor } from "@/lib/branding-colors";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
|
export type Branding = {
|
||||||
|
appName: string;
|
||||||
|
footerText: string;
|
||||||
|
accentColor: string;
|
||||||
|
logoUploadId: string | null;
|
||||||
|
faviconUploadId: string | null;
|
||||||
|
/** MIME-Typ des Favicons — der `<link>` braucht ihn. */
|
||||||
|
faviconMimeType: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_BRANDING: Branding = {
|
||||||
|
appName: "QR-Code-Generator",
|
||||||
|
footerText: "statische und dynamische QR-Codes",
|
||||||
|
accentColor: DEFAULT_ACCENT,
|
||||||
|
logoUploadId: null,
|
||||||
|
faviconUploadId: null,
|
||||||
|
faviconMimeType: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MAX_APP_NAME = 60;
|
||||||
|
export const MAX_FOOTER_TEXT = 120;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bewusst **ohne** Zwischenspeicher.
|
||||||
|
*
|
||||||
|
* Ein erster Versuch hielt den Wert 15 Sekunden lang fest, um die Abfrage je
|
||||||
|
* Seitenaufruf zu sparen. Das ging schief: Die API-Route und das Layout
|
||||||
|
* laufen in getrennten Modulinstanzen, das Verwerfen beim Speichern erreichte
|
||||||
|
* die andere Instanz also gar nicht. Nach dem Speichern blieb die Oberfläche
|
||||||
|
* unverändert — der schlechteste denkbare Eindruck bei einer Einstellung, die
|
||||||
|
* man ansieht.
|
||||||
|
*
|
||||||
|
* Es bleibt bei einer Abfrage auf den Primärschlüssel einer Tabelle mit einer
|
||||||
|
* einzigen Zeile. Das ist zu verschmerzen; jede Seite fragt ohnehin mehr ab.
|
||||||
|
*/
|
||||||
|
export async function getBranding(): Promise<Branding> {
|
||||||
|
let wert = DEFAULT_BRANDING;
|
||||||
|
try {
|
||||||
|
const zeile = await prisma.branding.findUnique({
|
||||||
|
where: { id: "branding" },
|
||||||
|
include: {
|
||||||
|
faviconUpload: { select: { mimeType: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (zeile) {
|
||||||
|
wert = {
|
||||||
|
appName: zeile.appName,
|
||||||
|
footerText: zeile.footerText,
|
||||||
|
accentColor: isHexColor(zeile.accentColor)
|
||||||
|
? zeile.accentColor
|
||||||
|
: DEFAULT_ACCENT,
|
||||||
|
logoUploadId: zeile.logoUploadId,
|
||||||
|
faviconUploadId: zeile.faviconUploadId,
|
||||||
|
faviconMimeType: zeile.faviconUpload?.mimeType ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ein Problem mit der Datenbank darf nicht dazu führen, dass gar keine
|
||||||
|
// Seite mehr erscheint — dann eben im Auslieferungszustand.
|
||||||
|
console.error("Erscheinungsbild konnte nicht geladen werden:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BrandingUpdate = {
|
||||||
|
appName?: string;
|
||||||
|
footerText?: string;
|
||||||
|
accentColor?: string;
|
||||||
|
/** `null` entfernt das Bild, `undefined` lässt es unverändert. */
|
||||||
|
logoUploadId?: string | null;
|
||||||
|
faviconUploadId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreibt das Erscheinungsbild und räumt ersetzte Bilder weg.
|
||||||
|
*
|
||||||
|
* Ohne das Aufräumen bliebe jedes einmal hochgeladene Logo für immer in der
|
||||||
|
* Datenbank liegen: Es gehört zu keinem Code, und die Aufräumroutine für
|
||||||
|
* herrenlose Dateien lässt „branding" bewusst in Ruhe.
|
||||||
|
*/
|
||||||
|
export async function saveBranding(update: BrandingUpdate): Promise<Branding> {
|
||||||
|
if (update.accentColor !== undefined && !isHexColor(update.accentColor)) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Die Farbe muss als Hex-Wert angegeben werden, z. B. #4F46E5.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorher = await prisma.branding.findUnique({
|
||||||
|
where: { id: "branding" },
|
||||||
|
select: { logoUploadId: true, faviconUploadId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const daten = {
|
||||||
|
...(update.appName !== undefined ? { appName: update.appName.trim() } : {}),
|
||||||
|
...(update.footerText !== undefined
|
||||||
|
? { footerText: update.footerText.trim() }
|
||||||
|
: {}),
|
||||||
|
...(update.accentColor !== undefined
|
||||||
|
? { accentColor: update.accentColor.trim().toUpperCase() }
|
||||||
|
: {}),
|
||||||
|
...(update.logoUploadId !== undefined
|
||||||
|
? { logoUploadId: update.logoUploadId }
|
||||||
|
: {}),
|
||||||
|
...(update.faviconUploadId !== undefined
|
||||||
|
? { faviconUploadId: update.faviconUploadId }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.branding.upsert({
|
||||||
|
where: { id: "branding" },
|
||||||
|
update: daten,
|
||||||
|
create: { id: "branding", ...daten },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Die ersetzten Dateien erst danach löschen — solange die neue Zeile nicht
|
||||||
|
// steht, wird noch die alte gebraucht.
|
||||||
|
const abgeloest = [
|
||||||
|
update.logoUploadId !== undefined &&
|
||||||
|
vorher?.logoUploadId &&
|
||||||
|
vorher.logoUploadId !== update.logoUploadId &&
|
||||||
|
vorher.logoUploadId,
|
||||||
|
update.faviconUploadId !== undefined &&
|
||||||
|
vorher?.faviconUploadId &&
|
||||||
|
vorher.faviconUploadId !== update.faviconUploadId &&
|
||||||
|
vorher.faviconUploadId,
|
||||||
|
].filter((id): id is string => typeof id === "string");
|
||||||
|
|
||||||
|
if (abgeloest.length > 0) {
|
||||||
|
await prisma.upload
|
||||||
|
.deleteMany({ where: { id: { in: abgeloest } } })
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getBranding();
|
||||||
|
}
|
||||||
@@ -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
-2
@@ -31,20 +31,29 @@ export type QrCodeWithRelations = QrCode & {
|
|||||||
group?: { name: string } | null;
|
group?: { name: string } | null;
|
||||||
domain?: { hostname: string } | null;
|
domain?: { hostname: string } | null;
|
||||||
createdBy?: { name: string | null; email: string } | null;
|
createdBy?: { name: string | null; email: string } | null;
|
||||||
|
aliases?: { slug: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
|
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 =
|
||||||
|
istStatisch || !code.slug
|
||||||
|
? ""
|
||||||
|
: qrType.hasLandingPage
|
||||||
? landingUrl(code.slug, code.domain?.hostname)
|
? landingUrl(code.slug, code.domain?.hostname)
|
||||||
: qrType.redirectTarget(data);
|
: qrType.redirectTarget(data);
|
||||||
summary = qrType.summary(data);
|
summary = qrType.summary(data);
|
||||||
@@ -59,11 +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),
|
||||||
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: {
|
||||||
|
|||||||
+17
-5
@@ -1,19 +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. */
|
||||||
|
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;
|
||||||
|
|||||||
+29
-11
@@ -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,16 +139,9 @@ export async function resolveQrRequest(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dateiname für den Download, abgeleitet aus dem Inhalt des Codes. */
|
/** Macht aus beliebigem Text einen brauchbaren Dateinamen-Bestandteil. */
|
||||||
export function downloadFilename(
|
function alsDateiname(text: string): string {
|
||||||
qrType: QrTypeModule,
|
return text
|
||||||
data: unknown,
|
|
||||||
extension: string,
|
|
||||||
): string {
|
|
||||||
let base = "qr-code";
|
|
||||||
try {
|
|
||||||
const summary = qrType
|
|
||||||
.summary(data)
|
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replaceAll("ä", "ae")
|
.replaceAll("ä", "ae")
|
||||||
.replaceAll("ö", "oe")
|
.replaceAll("ö", "oe")
|
||||||
@@ -155,7 +150,30 @@ export function downloadFilename(
|
|||||||
.replace(/[^a-z0-9]+/g, "-")
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
.replace(/^-+|-+$/g, "")
|
.replace(/^-+|-+$/g, "")
|
||||||
.slice(0, 60);
|
.slice(0, 60);
|
||||||
if (summary) base = `qr-${summary}`;
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(
|
||||||
|
qrType: QrTypeModule,
|
||||||
|
data: unknown,
|
||||||
|
extension: string,
|
||||||
|
label?: string | null,
|
||||||
|
): string {
|
||||||
|
const ausName = label ? alsDateiname(label) : "";
|
||||||
|
if (ausName) return `qr-${ausName}.${extension}`;
|
||||||
|
|
||||||
|
let base = "qr-code";
|
||||||
|
try {
|
||||||
|
const summary = qrType
|
||||||
|
.summary(data)
|
||||||
|
.toLowerCase();
|
||||||
|
const gesaeubert = alsDateiname(summary);
|
||||||
|
if (gesaeubert) base = `qr-${gesaeubert}`;
|
||||||
} catch {
|
} catch {
|
||||||
// Zusammenfassung ist optional — im Zweifel bleibt der Standardname.
|
// Zusammenfassung ist optional — im Zweifel bleibt der Standardname.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
export type InstanceSettings = {
|
||||||
|
/** Dürfen Bearbeiter den Kurz-Link selbst vergeben und ändern? */
|
||||||
|
editorsMaySetSlug: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: InstanceSettings = {
|
||||||
|
editorsMaySetSlug: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bewusst ohne Zwischenspeicher — dieselbe Erfahrung wie beim
|
||||||
|
* Erscheinungsbild: API-Route und Seite laufen in getrennten Modulinstanzen,
|
||||||
|
* ein Puffer wäre nach dem Speichern veraltet. Es ist eine Abfrage auf den
|
||||||
|
* Primärschlüssel einer Tabelle mit einer einzigen Zeile.
|
||||||
|
*/
|
||||||
|
export async function getSettings(): Promise<InstanceSettings> {
|
||||||
|
try {
|
||||||
|
const zeile = await prisma.instanceSetting.findUnique({
|
||||||
|
where: { id: "instance" },
|
||||||
|
});
|
||||||
|
return zeile
|
||||||
|
? { editorsMaySetSlug: zeile.editorsMaySetSlug }
|
||||||
|
: DEFAULT_SETTINGS;
|
||||||
|
} catch (error) {
|
||||||
|
// Ein Datenbankproblem darf nicht dazu führen, dass keine Seite erscheint.
|
||||||
|
// Im Zweifel gilt die zurückhaltendere Einstellung.
|
||||||
|
console.error("Einstellungen konnten nicht geladen werden:", error);
|
||||||
|
return DEFAULT_SETTINGS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSettings(
|
||||||
|
update: Partial<InstanceSettings>,
|
||||||
|
): Promise<InstanceSettings> {
|
||||||
|
await prisma.instanceSetting.upsert({
|
||||||
|
where: { id: "instance" },
|
||||||
|
update,
|
||||||
|
create: { id: "instance", ...update },
|
||||||
|
});
|
||||||
|
return getSettings();
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import type { QrCode } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
import { isValidSlug, normalizeSlug } from "@/lib/slug";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sucht den Code zu einem Kurz-Link — einschließlich der früheren Adressen.
|
||||||
|
*
|
||||||
|
* Zwei Stufen, aus einem Grund: Der häufige Fall ist ein gescannter Code, und
|
||||||
|
* der liefert die Adresse genau so, wie sie gespeichert ist. Dieser Zugriff
|
||||||
|
* geht über den exakten Index. Erst wenn er nichts findet, wird
|
||||||
|
* großschreibungsunabhängig gesucht und danach unter den früheren Adressen —
|
||||||
|
* beides seltene Fälle, etwa wenn jemand die Adresse abtippt.
|
||||||
|
*/
|
||||||
|
export async function findCodeBySlug(
|
||||||
|
domainId: string,
|
||||||
|
slug: string,
|
||||||
|
): Promise<{ code: QrCode; ueberFrueheAdresse: boolean } | null> {
|
||||||
|
if (!isValidSlug(slug)) return null;
|
||||||
|
|
||||||
|
const exakt = await prisma.qrCode.findUnique({
|
||||||
|
where: { domainId_slug: { domainId, slug } },
|
||||||
|
});
|
||||||
|
if (exakt) return { code: exakt, ueberFrueheAdresse: false };
|
||||||
|
|
||||||
|
const normalisiert = normalizeSlug(slug);
|
||||||
|
|
||||||
|
const egalGeschrieben = await prisma.qrCode.findFirst({
|
||||||
|
where: { domainId, slug: { equals: normalisiert, mode: "insensitive" } },
|
||||||
|
});
|
||||||
|
if (egalGeschrieben) {
|
||||||
|
return { code: egalGeschrieben, ueberFrueheAdresse: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const alias = await prisma.qrCodeAlias.findFirst({
|
||||||
|
where: { domainId, slug: { equals: normalisiert, mode: "insensitive" } },
|
||||||
|
include: { qrCode: true },
|
||||||
|
});
|
||||||
|
if (alias) return { code: alias.qrCode, ueberFrueheAdresse: true };
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ist dieser Kurz-Link auf dieser Domain noch frei?
|
||||||
|
*
|
||||||
|
* Belegt ist er auch dann, wenn ihn ein anderer Code als **frühere** Adresse
|
||||||
|
* hält — sonst könnte man einem fremden Code seine gedruckten Adressen
|
||||||
|
* wegnehmen.
|
||||||
|
*/
|
||||||
|
export async function assertSlugAvailable(
|
||||||
|
domainId: string,
|
||||||
|
slug: string,
|
||||||
|
/** Der eigene Code darf seine eigenen Adressen natürlich behalten. */
|
||||||
|
eigenerCodeId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const normalisiert = normalizeSlug(slug);
|
||||||
|
|
||||||
|
const belegtVomCode = await prisma.qrCode.findFirst({
|
||||||
|
where: {
|
||||||
|
domainId,
|
||||||
|
slug: { equals: normalisiert, mode: "insensitive" },
|
||||||
|
...(eigenerCodeId ? { id: { not: eigenerCodeId } } : {}),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (belegtVomCode) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
`Der Kurz-Link „${normalisiert}" ist auf dieser Domain schon vergeben.`,
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const belegtAlsAlias = await prisma.qrCodeAlias.findFirst({
|
||||||
|
where: {
|
||||||
|
domainId,
|
||||||
|
slug: { equals: normalisiert, mode: "insensitive" },
|
||||||
|
...(eigenerCodeId ? { qrCodeId: { not: eigenerCodeId } } : {}),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (belegtAlsAlias) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
`Der Kurz-Link „${normalisiert}" gehört als frühere Adresse noch zu einem ` +
|
||||||
|
`anderen Code. Wer ihn freigibt, kann ihn danach hier verwenden.`,
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+91
-3
@@ -8,8 +8,12 @@ const ALPHABET = "23456789abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ";
|
|||||||
|
|
||||||
export const SLUG_LENGTH = 8;
|
export const SLUG_LENGTH = 8;
|
||||||
|
|
||||||
/** Muster, gegen das eingehende Slugs geprüft werden. */
|
/** Höchstens so viele durch `/` getrennte Abschnitte. */
|
||||||
export const SLUG_PATTERN = /^[0-9A-Za-z]{4,32}$/;
|
export const MAX_SLUG_SEGMENTS = 3;
|
||||||
|
/** Länge eines Abschnitts. */
|
||||||
|
export const MAX_SEGMENT_LENGTH = 48;
|
||||||
|
/** Länge des ganzen Kurz-Links. */
|
||||||
|
export const MAX_SLUG_LENGTH = 100;
|
||||||
|
|
||||||
export function generateSlug(length: number = SLUG_LENGTH): string {
|
export function generateSlug(length: number = SLUG_LENGTH): string {
|
||||||
let slug = "";
|
let slug = "";
|
||||||
@@ -19,6 +23,90 @@ export function generateSlug(length: number = SLUG_LENGTH): string {
|
|||||||
return slug;
|
return slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vergleichsform eines Kurz-Links.
|
||||||
|
*
|
||||||
|
* Groß- und Kleinschreibung spielt beim Aufrufen keine Rolle: Wer
|
||||||
|
* `qr.firma.de/r/Sommeraktion` abtippt, soll dort landen, wo
|
||||||
|
* `sommeraktion` liegt. Deshalb wird überall gegen diese Form verglichen —
|
||||||
|
* beim Auflösen wie bei der Prüfung auf Eindeutigkeit.
|
||||||
|
*/
|
||||||
|
export function normalizeSlug(value: string): string {
|
||||||
|
return value.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein einzelner Abschnitt: Kleinbuchstaben, Ziffern, Bindestriche. */
|
||||||
|
const SEGMENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft einen Kurz-Link, wie er in der Adresse stehen darf.
|
||||||
|
*
|
||||||
|
* Erlaubt sind Kleinbuchstaben, Ziffern, Bindestriche und `/` als Trenner —
|
||||||
|
* damit sind Adressen wie `kunde-a/sommeraktion` möglich. Bewusst **nicht**
|
||||||
|
* erlaubt: Umlaute und Akzente (sie werden in Adressen unleserlich kodiert),
|
||||||
|
* Leerzeichen, Unterstriche sowie Bindestriche am Rand eines Abschnitts.
|
||||||
|
*
|
||||||
|
* Selbst erzeugte Kurz-Links aus dem gemischten Alphabet oben bestehen diese
|
||||||
|
* Prüfung ebenfalls, weil verglichen wird, nachdem kleingeschrieben wurde.
|
||||||
|
*
|
||||||
|
* Führende, doppelte und abschließende Schrägstriche gelten als ungültig — und
|
||||||
|
* zwar auch beim Auflösen. Sonst führten `/r/sommer` und `/r//sommer` auf
|
||||||
|
* denselben Code, und es gäbe zwei Adressen für dieselbe Sache.
|
||||||
|
*/
|
||||||
export function isValidSlug(value: string): boolean {
|
export function isValidSlug(value: string): boolean {
|
||||||
return SLUG_PATTERN.test(value);
|
const slug = normalizeSlug(value);
|
||||||
|
if (slug.length === 0 || slug.length > MAX_SLUG_LENGTH) return false;
|
||||||
|
if (slug.startsWith("/") || slug.endsWith("/") || slug.includes("//")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const abschnitte = slug.split("/");
|
||||||
|
if (abschnitte.length > MAX_SLUG_SEGMENTS) return false;
|
||||||
|
|
||||||
|
return abschnitte.every(
|
||||||
|
(abschnitt) =>
|
||||||
|
abschnitt.length > 0 &&
|
||||||
|
abschnitt.length <= MAX_SEGMENT_LENGTH &&
|
||||||
|
SEGMENT.test(abschnitt),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft einen selbst gewählten Kurz-Link und liefert eine deutsche Begründung,
|
||||||
|
* wenn er nicht taugt. Ausführlicher als `isValidSlug`, weil hier jemand
|
||||||
|
* zusieht und wissen will, was falsch war.
|
||||||
|
*/
|
||||||
|
export function describeSlugProblem(eingabe: string): string | null {
|
||||||
|
const roh = eingabe.trim();
|
||||||
|
if (roh.length === 0) return "Bitte gib einen Kurz-Link ein.";
|
||||||
|
|
||||||
|
if (/^\/|\/$/.test(roh)) {
|
||||||
|
return "Der Kurz-Link darf nicht mit „/“ beginnen oder enden.";
|
||||||
|
}
|
||||||
|
if (roh.includes("//")) {
|
||||||
|
return "Zwei Schrägstriche hintereinander sind nicht möglich.";
|
||||||
|
}
|
||||||
|
if (roh.length > MAX_SLUG_LENGTH) {
|
||||||
|
return `Der Kurz-Link ist zu lang (höchstens ${MAX_SLUG_LENGTH} Zeichen).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = normalizeSlug(roh);
|
||||||
|
const abschnitte = slug.split("/");
|
||||||
|
|
||||||
|
if (abschnitte.length > MAX_SLUG_SEGMENTS) {
|
||||||
|
return `Höchstens ${MAX_SLUG_SEGMENTS} durch „/“ getrennte Abschnitte.`;
|
||||||
|
}
|
||||||
|
for (const abschnitt of abschnitte) {
|
||||||
|
if (abschnitt.length > MAX_SEGMENT_LENGTH) {
|
||||||
|
return `Jeder Abschnitt darf höchstens ${MAX_SEGMENT_LENGTH} Zeichen lang sein.`;
|
||||||
|
}
|
||||||
|
if (/^-|-$/.test(abschnitt)) {
|
||||||
|
return "Ein Bindestrich darf nicht am Anfang oder Ende eines Abschnitts stehen.";
|
||||||
|
}
|
||||||
|
if (!SEGMENT.test(abschnitt)) {
|
||||||
|
return "Erlaubt sind Buchstaben ohne Umlaute, Ziffern, Bindestriche und „/“ als Trenner.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,15 @@
|
|||||||
* Dateiarten und die Größenformatierung.
|
* Dateiarten und die Größenformatierung.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** Bilder, die die Oberfläche selbst anzeigt. */
|
||||||
|
const BILD_TYPEN = [
|
||||||
|
"image/png",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/webp",
|
||||||
|
"image/gif",
|
||||||
|
"image/avif",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export const UPLOAD_KINDS = {
|
export const UPLOAD_KINDS = {
|
||||||
pdf: {
|
pdf: {
|
||||||
label: "PDF-Dokument",
|
label: "PDF-Dokument",
|
||||||
@@ -14,21 +23,52 @@ export const UPLOAD_KINDS = {
|
|||||||
},
|
},
|
||||||
image: {
|
image: {
|
||||||
label: "Bild",
|
label: "Bild",
|
||||||
mimeTypes: [
|
mimeTypes: BILD_TYPEN,
|
||||||
"image/png",
|
|
||||||
"image/jpeg",
|
|
||||||
"image/webp",
|
|
||||||
"image/gif",
|
|
||||||
"image/avif",
|
|
||||||
],
|
|
||||||
accept: "image/png,image/jpeg,image/webp,image/gif,image/avif",
|
accept: "image/png,image/jpeg,image/webp,image/gif,image/avif",
|
||||||
},
|
},
|
||||||
|
logo: {
|
||||||
|
label: "Logo",
|
||||||
|
mimeTypes: BILD_TYPEN,
|
||||||
|
accept: "image/png,image/jpeg,image/webp,image/avif",
|
||||||
|
// Ein Logo in der Kopfzeile ist ein paar Dutzend Kilobyte groß. Die
|
||||||
|
// Grenze soll verhindern, dass jemand ein Foto aus der Kamera hochlädt,
|
||||||
|
// das dann bei jedem Seitenaufruf mitgeht.
|
||||||
|
maxBytes: 2 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
favicon: {
|
||||||
|
label: "Favicon",
|
||||||
|
// Zusätzlich ICO — viele haben genau das noch herumliegen.
|
||||||
|
mimeTypes: [...BILD_TYPEN, "image/x-icon"],
|
||||||
|
accept: "image/png,image/x-icon,.ico,image/webp",
|
||||||
|
maxBytes: 512 * 1024,
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wozu die Datei gehört.
|
||||||
|
*
|
||||||
|
* `code` hängt an einem QR-Code und verschwindet mit ihm; `branding` gehört
|
||||||
|
* zur Oberfläche und darf deshalb nicht als herrenlos weggeräumt werden.
|
||||||
|
*/
|
||||||
|
export const UPLOAD_PURPOSES = ["code", "branding"] as const;
|
||||||
|
export type UploadPurpose = (typeof UPLOAD_PURPOSES)[number];
|
||||||
|
|
||||||
|
export function uploadPurposeOf(kind: UploadKind): UploadPurpose {
|
||||||
|
return kind === "logo" || kind === "favicon" ? "branding" : "code";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Obergrenze dieser Art — sonst gilt die allgemeine aus MAX_UPLOAD_MB. */
|
||||||
|
export function kindMaxBytes(kind: UploadKind): number | null {
|
||||||
|
const eintrag = UPLOAD_KINDS[kind] as { maxBytes?: number };
|
||||||
|
return eintrag.maxBytes ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export type UploadKind = keyof typeof UPLOAD_KINDS;
|
export type UploadKind = keyof typeof UPLOAD_KINDS;
|
||||||
|
|
||||||
export function isUploadKind(value: unknown): value is UploadKind {
|
export function isUploadKind(value: unknown): value is UploadKind {
|
||||||
return value === "pdf" || value === "image";
|
return (
|
||||||
|
typeof value === "string" && Object.hasOwn(UPLOAD_KINDS, value)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatBytes(bytes: number): string {
|
export function formatBytes(bytes: number): string {
|
||||||
|
|||||||
+19
-3
@@ -4,7 +4,9 @@ import { prisma } from "@/lib/db";
|
|||||||
import { QrRequestError } from "@/lib/qr/type-module";
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
import {
|
import {
|
||||||
formatBytes,
|
formatBytes,
|
||||||
|
kindMaxBytes,
|
||||||
UPLOAD_KINDS,
|
UPLOAD_KINDS,
|
||||||
|
uploadPurposeOf,
|
||||||
type UploadKind,
|
type UploadKind,
|
||||||
} from "@/lib/uploads.shared";
|
} from "@/lib/uploads.shared";
|
||||||
|
|
||||||
@@ -13,6 +15,8 @@ export {
|
|||||||
UPLOAD_KINDS,
|
UPLOAD_KINDS,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
isUploadKind,
|
isUploadKind,
|
||||||
|
kindMaxBytes,
|
||||||
|
uploadPurposeOf,
|
||||||
} from "@/lib/uploads.shared";
|
} from "@/lib/uploads.shared";
|
||||||
export type { UploadKind } from "@/lib/uploads.shared";
|
export type { UploadKind } from "@/lib/uploads.shared";
|
||||||
|
|
||||||
@@ -41,6 +45,10 @@ export function sniffMimeType(bytes: Uint8Array): string | null {
|
|||||||
if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png";
|
if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png";
|
||||||
if (starts(0xff, 0xd8, 0xff)) return "image/jpeg";
|
if (starts(0xff, 0xd8, 0xff)) return "image/jpeg";
|
||||||
if (starts(0x47, 0x49, 0x46, 0x38)) return "image/gif"; // GIF8
|
if (starts(0x47, 0x49, 0x46, 0x38)) return "image/gif"; // GIF8
|
||||||
|
// ICO: Reserviert 0, Typ 1, dann die Anzahl der Bilder (mindestens eines).
|
||||||
|
if (starts(0x00, 0x00, 0x01, 0x00) && (bytes[4] | bytes[5]) !== 0) {
|
||||||
|
return "image/x-icon";
|
||||||
|
}
|
||||||
|
|
||||||
// RIFF....WEBP
|
// RIFF....WEBP
|
||||||
if (
|
if (
|
||||||
@@ -102,7 +110,10 @@ export async function storeUpload(
|
|||||||
file: File,
|
file: File,
|
||||||
kind: UploadKind,
|
kind: UploadKind,
|
||||||
): Promise<StoredUpload> {
|
): Promise<StoredUpload> {
|
||||||
const limit = maxUploadBytes();
|
// Die engere der beiden Grenzen gewinnt: die allgemeine aus MAX_UPLOAD_MB
|
||||||
|
// und die der Dateiart.
|
||||||
|
const eigene = kindMaxBytes(kind);
|
||||||
|
const limit = eigene === null ? maxUploadBytes() : Math.min(eigene, maxUploadBytes());
|
||||||
if (file.size === 0) {
|
if (file.size === 0) {
|
||||||
throw new QrRequestError("Die Datei ist leer.");
|
throw new QrRequestError("Die Datei ist leer.");
|
||||||
}
|
}
|
||||||
@@ -126,6 +137,8 @@ export async function storeUpload(
|
|||||||
throw new QrRequestError(
|
throw new QrRequestError(
|
||||||
kind === "pdf"
|
kind === "pdf"
|
||||||
? "Das ist keine PDF-Datei. Bitte lade ein PDF hoch."
|
? "Das ist keine PDF-Datei. Bitte lade ein PDF hoch."
|
||||||
|
: kind === "favicon"
|
||||||
|
? "Das ist kein unterstütztes Bild. Erlaubt sind PNG, ICO, WebP, JPEG, GIF und AVIF."
|
||||||
: "Das ist kein unterstütztes Bild. Erlaubt sind PNG, JPEG, WebP, GIF und AVIF.",
|
: "Das ist kein unterstütztes Bild. Erlaubt sind PNG, JPEG, WebP, GIF und AVIF.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -134,11 +147,12 @@ export async function storeUpload(
|
|||||||
data: {
|
data: {
|
||||||
filename: sanitizeFilename(
|
filename: sanitizeFilename(
|
||||||
file.name,
|
file.name,
|
||||||
kind === "pdf" ? "dokument.pdf" : "bild",
|
kind === "pdf" ? "dokument.pdf" : kind === "favicon" ? "favicon" : "bild",
|
||||||
),
|
),
|
||||||
mimeType: erkannt,
|
mimeType: erkannt,
|
||||||
size: bytes.byteLength,
|
size: bytes.byteLength,
|
||||||
data: Buffer.from(bytes),
|
data: Buffer.from(bytes),
|
||||||
|
purpose: uploadPurposeOf(kind),
|
||||||
},
|
},
|
||||||
select: { id: true, filename: true, mimeType: true, size: true },
|
select: { id: true, filename: true, mimeType: true, size: true },
|
||||||
});
|
});
|
||||||
@@ -188,7 +202,9 @@ const ORPHAN_HOURS = 24;
|
|||||||
export async function cleanupOrphanedUploads(): Promise<number> {
|
export async function cleanupOrphanedUploads(): Promise<number> {
|
||||||
const grenze = new Date(Date.now() - ORPHAN_HOURS * 60 * 60 * 1000);
|
const grenze = new Date(Date.now() - ORPHAN_HOURS * 60 * 60 * 1000);
|
||||||
const { count } = await prisma.upload.deleteMany({
|
const { count } = await prisma.upload.deleteMany({
|
||||||
where: { qrCodeId: null, createdAt: { lt: grenze } },
|
// Nur Dateien, die zu einem Code gehören sollten. Logo und Favicon haben
|
||||||
|
// nie einen Code und wären sonst nach einem Tag verschwunden.
|
||||||
|
where: { qrCodeId: null, purpose: "code", createdAt: { lt: grenze } },
|
||||||
});
|
});
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,10 +70,43 @@ BRANCH="$(git rev-parse --abbrev-ref HEAD)"
|
|||||||
|
|
||||||
info "Hole neuen Stand von origin/$BRANCH …"
|
info "Hole neuen Stand von origin/$BRANCH …"
|
||||||
git fetch --quiet origin
|
git fetch --quiet origin
|
||||||
|
|
||||||
# --ff-only: lieber abbrechen als eine Merge-Situation erzeugen, die auf einem
|
# --ff-only: lieber abbrechen als eine Merge-Situation erzeugen, die auf einem
|
||||||
# Server niemand auflösen will.
|
# Server niemand auflösen will.
|
||||||
git merge --ff-only --quiet "origin/$BRANCH" ||
|
if ! git merge --ff-only --quiet "origin/$BRANCH" 2>/dev/null; then
|
||||||
fail "Der lokale Stand lässt sich nicht ohne Merge aktualisieren. Bitte von Hand prüfen."
|
echo "" >&2
|
||||||
|
echo "✗ Der lokale Stand lässt sich nicht ohne Merge aktualisieren." >&2
|
||||||
|
echo "" >&2
|
||||||
|
|
||||||
|
if git merge-base --is-ancestor HEAD "origin/$BRANCH" 2>/dev/null; then
|
||||||
|
# Kann eigentlich nicht sein — dann wäre das Vorspulen geglückt.
|
||||||
|
echo " Grund: unklar. Bitte 'git status' und 'git log --oneline -5' ansehen." >&2
|
||||||
|
elif ! git merge-base HEAD "origin/$BRANCH" >/dev/null 2>&1; then
|
||||||
|
# Kein gemeinsamer Vorfahre: Die Historie wurde neu geschrieben, etwa
|
||||||
|
# durch einen Force-Push. Der Inhalt ist derselbe, die Commits haben
|
||||||
|
# nur neue Kennungen.
|
||||||
|
cat >&2 <<'HINWEIS'
|
||||||
|
Grund: Der Verlauf auf dem Server wurde neu geschrieben (Force-Push).
|
||||||
|
Alte und neue Commits haben keinen gemeinsamen Vorfahren mehr.
|
||||||
|
|
||||||
|
Der lokale Stand lässt sich verwerfen und durch den vom Server ersetzen:
|
||||||
|
|
||||||
|
git fetch origin
|
||||||
|
git reset --hard origin/main
|
||||||
|
./update.sh --force
|
||||||
|
|
||||||
|
Das betrifft nur versionierte Dateien. Die .env und alles, was nicht im
|
||||||
|
Repository liegt, bleibt unberührt.
|
||||||
|
HINWEIS
|
||||||
|
else
|
||||||
|
echo " Grund: Hier liegen eigene Commits, die es auf dem Server nicht gibt:" >&2
|
||||||
|
git --no-pager log --oneline "origin/$BRANCH..HEAD" | sed 's/^/ /' >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo " Entweder pushen — oder mit 'git reset --hard origin/$BRANCH' verwerfen." >&2
|
||||||
|
fi
|
||||||
|
echo "" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
AFTER="$(git rev-parse HEAD)"
|
AFTER="$(git rev-parse HEAD)"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user