Compare commits
6
Commits
v1.5.0
..
a7af9c9290
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7af9c9290 | ||
|
|
101f664cef | ||
|
|
142536140c | ||
|
|
1cf1df54df | ||
|
|
b96e9ddbe5 | ||
|
|
586e1f042f |
@@ -0,0 +1,74 @@
|
|||||||
|
# Änderungen
|
||||||
|
|
||||||
|
Was in welcher Fassung dazugekommen ist. Dieselbe Liste steht in der
|
||||||
|
Anwendung unter **Version und Änderungen**, erreichbar über die
|
||||||
|
Versionsnummer in der Fußzeile.
|
||||||
|
|
||||||
|
> Diese Datei wird erzeugt: `npm run changelog`.
|
||||||
|
> Gepflegt wird der Verlauf in `src/lib/changelog.ts`.
|
||||||
|
|
||||||
|
## 1.6.0 — 21.08.2026
|
||||||
|
|
||||||
|
Sichtbare Version mit Änderungsverlauf.
|
||||||
|
|
||||||
|
- Die laufende Version steht in der Fußzeile und führt auf eine Seite mit dem vollständigen Änderungsverlauf.
|
||||||
|
- Der Verlauf wird an einer Stelle gepflegt; die CHANGELOG.md im Projekt entsteht daraus.
|
||||||
|
- Die Roadmap verweist für Vergangenes auf den Changelog und behält den Blick nach vorn.
|
||||||
|
|
||||||
|
## 1.5.0 — 20.08.2026
|
||||||
|
|
||||||
|
Statische Codes lassen sich benennen und ablegen.
|
||||||
|
|
||||||
|
- Das Feld „Name“ gilt in beiden Betriebsarten, nicht mehr nur bei dynamischen Codes.
|
||||||
|
- Der Name bestimmt den Dateinamen beim Herunterladen.
|
||||||
|
- Ein statischer Code lässt sich in „Meine Codes“ ablegen — als Schnappschuss ohne Kurz-Link und ohne Scan-Zähler.
|
||||||
|
|
||||||
|
## 1.4.0 — 17.08.2026
|
||||||
|
|
||||||
|
Sprechende Kurz-Links und eine früh erkannte Fehlkonfiguration.
|
||||||
|
|
||||||
|
- Kurz-Links selbst vergeben und nachträglich ändern, mit „/“ als Trenner.
|
||||||
|
- Die vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — gedruckte Codes bleiben gültig.
|
||||||
|
- Vergeben und Ändern ist Administratoren vorbehalten und in der Verwaltung für Bearbeiter freigebbar.
|
||||||
|
- Weicht BASE_URL von der aufgerufenen Adresse ab, meldet die Anmeldung das sofort statt erst am Ende.
|
||||||
|
- Auf der Anmeldeseite steht nicht mehr, wie die Anmeldung konfiguriert ist.
|
||||||
|
|
||||||
|
## 1.3.0 — 16.08.2026
|
||||||
|
|
||||||
|
Eigenes Erscheinungsbild und ein Dark Mode.
|
||||||
|
|
||||||
|
- Name, Logo, Favicon und Akzentfarbe sind im Admin-Bereich einstellbar.
|
||||||
|
- Hell und dunkel folgen dem Betriebssystem, mit Umschalter für eine eigene Wahl.
|
||||||
|
- Das Update-Skript erklärt, warum ein Vorspulen scheitert, statt nur abzubrechen.
|
||||||
|
- Die Authentik-Anleitung sagt deutlicher, wie die Redirect URI zu bilden ist.
|
||||||
|
|
||||||
|
## 1.2.0 — 15.08.2026
|
||||||
|
|
||||||
|
Single Sign-on, Dokumentation und eine Sicherheitsdurchsicht.
|
||||||
|
|
||||||
|
- Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem ID-Token.
|
||||||
|
- Bedienungsanleitung mit Bildern, Funktionsübersicht und Roadmap.
|
||||||
|
- Anmeldung und Sitzungen nach OWASP durchgesehen und gehärtet: stärkere Passwort-Parameter, Längengrenze, Herkunftsprüfung, Schutzheader, absolute Sitzungsdauer.
|
||||||
|
- Auf der Startseite steht „nur dynamisch“ statt eines nichtssagenden Abzeichens an jeder Kachel.
|
||||||
|
|
||||||
|
## 1.1.0 — 14.08.2026
|
||||||
|
|
||||||
|
Alle fünfzehn QR-Typen, Anmeldung, Gruppen und mehrere Domains.
|
||||||
|
|
||||||
|
- Vierzehn weitere QR-Typen — von vCard und WLAN bis PDF, Bild und Feedback.
|
||||||
|
- Landingpages als eigene Gattung für Typen ohne fremdes Ziel.
|
||||||
|
- Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen.
|
||||||
|
- Anmeldung, Benutzer, Gruppen und die Rollen Administrator, Bearbeiter und Betrachter.
|
||||||
|
- Mehrere Domains für Kurz-URLs, den Gruppen zugeteilt.
|
||||||
|
- „Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter.
|
||||||
|
- Einzelne QR-Typen lassen sich in der Verwaltung ausblenden.
|
||||||
|
|
||||||
|
## 1.0.0 — 13.08.2026
|
||||||
|
|
||||||
|
Erste lauffähige Fassung.
|
||||||
|
|
||||||
|
- Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, Migrationen beim Start.
|
||||||
|
- QR-Typ Website, statisch und dynamisch, mit Kurz-URL, Weiterleitung und Scan-Zähler.
|
||||||
|
- Gestaltung mit Farben, Kontrastwarnung, Größe, Fehlerkorrektur und Rand.
|
||||||
|
- Download als PNG und SVG, serverseitig gerendert.
|
||||||
|
- Update-Skript für Linux, macOS und Windows.
|
||||||
@@ -34,6 +34,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
|
|||||||
eigene Wahl.
|
eigene Wahl.
|
||||||
- **Sprechende Kurz-Links:** `qr.firma.de/r/sommeraktion` statt Zufallszeichen,
|
- **Sprechende Kurz-Links:** `qr.firma.de/r/sommeraktion` statt Zufallszeichen,
|
||||||
nachträglich änderbar — die vorherige Adresse bleibt gültig.
|
nachträglich änderbar — die vorherige Adresse bleibt gültig.
|
||||||
|
- **Sichtbare Version:** Die Fußzeile nennt die laufende Fassung und führt
|
||||||
|
zum Änderungsverlauf — nach einem Update sofort erkennbar, ob es ankam.
|
||||||
- **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start
|
- **Zwei Container, kein Reverse Proxy nötig.** Migrationen laufen beim Start
|
||||||
von selbst; alles Nötige steht in einer `.env`.
|
von selbst; alles Nötige steht in einer `.env`.
|
||||||
|
|
||||||
@@ -48,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
|
||||||
|
|
||||||
@@ -615,13 +618,18 @@ Dazu kommen
|
|||||||
|
|
||||||
- ein **Suchfeld** über Name, Ziel, Kurz-URL und Typ,
|
- ein **Suchfeld** über Name, Ziel, Kurz-URL und Typ,
|
||||||
- eine **Sortierung** nach Datum, Name oder Scan-Anzahl,
|
- eine **Sortierung** nach Datum, Name oder Scan-Anzahl,
|
||||||
- ein **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen.
|
- ein **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen,
|
||||||
|
- ein **Filter nach Art**, sobald dynamische und statische Codes
|
||||||
|
nebeneinander liegen.
|
||||||
|
|
||||||
Auf schmalen Bildschirmen blendet die Liste die hinteren Spalten aus; die
|
Auf schmalen Bildschirmen blendet die Liste die hinteren Spalten aus; die
|
||||||
Kurz-URL rückt dann unter den Namen. Zum Herunterladen dient dort die
|
Kurz-URL rückt dann unter den Namen. Zum Herunterladen dient dort die
|
||||||
Kartenansicht.
|
Kartenansicht.
|
||||||
|
|
||||||
Statische Codes erscheinen hier nicht, weil sie nicht gespeichert werden.
|
Abgelegte **statische Codes** stehen mit in der Liste, tragen aber ein
|
||||||
|
Abzeichen „statisch“ statt einer Kurz-URL und zählen keine Scans. Steht der
|
||||||
|
Filter auf „Nur statische“, fällt die Scan-Spalte weg und die Sortierung nach
|
||||||
|
Scans mit ihr — bei einer Liste aus lauter Nullen sagt sie nichts.
|
||||||
|
|
||||||
Bei einem **Feedback-Code** führt aus der Zeile ein Stern-Symbol zur
|
Bei einem **Feedback-Code** führt aus der Zeile ein Stern-Symbol zur
|
||||||
Auswertung: Durchschnitt, Verteilung der Noten und die abgegebenen Kommentare.
|
Auswertung: Durchschnitt, Verteilung der Noten und die abgegebenen Kommentare.
|
||||||
|
|||||||
@@ -500,6 +500,21 @@ alles zeigt und Typen aus späteren Versionen automatisch erscheinen.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Version und Änderungen
|
||||||
|
|
||||||
|
Die Fußzeile nennt die laufende Version und führt auf eine Seite mit dem
|
||||||
|
vollständigen Änderungsverlauf. Nach einem Update ist das die schnellste
|
||||||
|
Antwort auf die Frage, ob der Container wirklich mit dem neuen Abbild läuft:
|
||||||
|
Steht dort noch die alte Nummer, ist er es nicht.
|
||||||
|
|
||||||
|
Der Verlauf wird an **einer** Stelle gepflegt (`src/lib/changelog.ts`).
|
||||||
|
Daraus entstehen sowohl die Seite in der Anwendung als auch die
|
||||||
|
[CHANGELOG.md](../CHANGELOG.md) im Projekt (`npm run changelog`); eine
|
||||||
|
Prüfung wacht darüber, dass beides zusammenpasst. Zu jeder Version gibt es
|
||||||
|
einen Git-Tag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Schnittstelle
|
## Schnittstelle
|
||||||
|
|
||||||
Alles, was die Oberfläche tut, geht auch über HTTP:
|
Alles, was die Oberfläche tut, geht auch über HTTP:
|
||||||
|
|||||||
+9
-87
@@ -7,94 +7,16 @@ Aufwände sind grobe Einschätzungen, keine Zusagen.
|
|||||||
|
|
||||||
## Gebaut
|
## Gebaut
|
||||||
|
|
||||||
### Version 1 — Grundgerüst (13.08.2026)
|
Der vollständige Verlauf mit Versionsnummern steht im
|
||||||
|
[Changelog](../CHANGELOG.md) — und in der Anwendung selbst unter
|
||||||
|
**Version und Änderungen**, erreichbar über die Versionsnummer in der
|
||||||
|
Fußzeile.
|
||||||
|
|
||||||
- Next.js-Anwendung und PostgreSQL als Docker-Compose-Stack, ohne Reverse
|
Kurz gefasst: Version 1.0.0 (13.08.2026) brachte das Grundgerüst mit dem Typ
|
||||||
Proxy, Migrationen beim Start
|
Website, 1.1.0 alle fünfzehn Typen samt Anmeldung, Gruppen und Domains,
|
||||||
- QR-Typ-Registry als Dispatch-Tabelle — ein neuer Typ ist ein Modul plus ein
|
1.2.0 Single Sign-on und die Sicherheitsdurchsicht, 1.3.0 Erscheinungsbild
|
||||||
Formular, ohne Eingriff in den übrigen Code
|
und Dark Mode, 1.4.0 sprechende Kurz-Links, 1.5.0 benennbare statische Codes
|
||||||
- Typ **Website**, statisch und dynamisch, mit Kurz-URL, 302-Weiterleitung und
|
und 1.6.0 diesen Versionshinweis.
|
||||||
Scan-Zähler
|
|
||||||
- Gestaltung: Farben mit Kontrastwarnung, Größe, Fehlerkorrektur, Rand
|
|
||||||
- Download als PNG und SVG, serverseitig gerendert
|
|
||||||
- „Meine Codes“ mit Ziel ändern und Löschen
|
|
||||||
- Deutsche Oberfläche, deutsche Fehlermeldungen, Validierung mit Zod
|
|
||||||
- Update-Skript für Linux/macOS und Windows
|
|
||||||
|
|
||||||
### Typen einzeln freigeben (14.08.2026)
|
|
||||||
|
|
||||||
Ausblenden nicht gebrauchter Typen in der Verwaltung. Bereits erstellte Codes
|
|
||||||
eines abgeschalteten Typs funktionieren weiter.
|
|
||||||
|
|
||||||
### Phase A — acht weitere Typen (14.08.2026)
|
|
||||||
|
|
||||||
vCard, App-Link, WhatsApp, E-Mail, SMS, Standort, Zahlung (GiroCode und
|
|
||||||
Zahlungslink), WLAN.
|
|
||||||
|
|
||||||
### Phase B1 — Anmeldung, Gruppen, Rollen (14.08.2026)
|
|
||||||
|
|
||||||
Abschaltbare Anmeldung, Benutzerverwaltung, Gruppen mit den Rollen
|
|
||||||
Administrator, Bearbeiter und Betrachter, Sitzungen in der Datenbank,
|
|
||||||
scrypt-Passwörter, Sperre nach Fehlversuchen.
|
|
||||||
|
|
||||||
### Phase B2 — mehrere Domains (14.08.2026)
|
|
||||||
|
|
||||||
Domains für Kurz-URLs, den Gruppen zugeteilt. Slug je Domain eindeutig,
|
|
||||||
Host-Header gegen die registrierten Domains geprüft.
|
|
||||||
|
|
||||||
### Listenansicht (14.08.2026)
|
|
||||||
|
|
||||||
„Meine Codes“ als Liste mit Suche, Sortierung und Gruppenfilter — gedacht für
|
|
||||||
viele Projekte nebeneinander. Karten bleiben als zweite Ansicht.
|
|
||||||
|
|
||||||
### Phase C — die letzten sechs Typen (14.08.2026)
|
|
||||||
|
|
||||||
Mehrere Links, Social Media, Video, PDF, Bild, Feedback. Dazu:
|
|
||||||
|
|
||||||
- Landingpages als eigene Gattung (`/s/{slug}`), ohne App-Navigation
|
|
||||||
- Datei-Upload mit Inhaltsprüfung, Größengrenze und Aufräumen
|
|
||||||
- Feedback mit anonymer Erhebung und Auswertung
|
|
||||||
|
|
||||||
Damit sind **alle 15 vorgesehenen Typen** gebaut.
|
|
||||||
|
|
||||||
### Single Sign-on (15.08.2026)
|
|
||||||
|
|
||||||
Anmeldung über Authentik und andere OIDC-Anbieter, mit PKCE und geprüftem
|
|
||||||
ID-Token.
|
|
||||||
|
|
||||||
### Erscheinungsbild (16.08.2026)
|
|
||||||
|
|
||||||
Name, Logo, Favicon und Akzentfarbe im Admin-Bereich einstellbar. Die Farbe
|
|
||||||
wirkt über die Farbvariablen, die Tailwind ohnehin benutzt — deshalb ohne
|
|
||||||
Umbau der rund 60 eingefärbten Stellen.
|
|
||||||
|
|
||||||
### Dark Mode (16.08.2026)
|
|
||||||
|
|
||||||
Folgt dem Betriebssystem, mit Umschalter für Hell, Dunkel und System.
|
|
||||||
Umgesetzt über `light-dark()` statt über `dark:`-Klassen — deshalb ohne
|
|
||||||
Änderung an den rund 440 Farbklassen und ohne JavaScript für die Systemwahl.
|
|
||||||
|
|
||||||
### Sprechende Kurz-Links (17.08.2026)
|
|
||||||
|
|
||||||
Kurz-Links selbst vergeben und nachträglich ändern, mit `/` als Trenner. Die
|
|
||||||
vorherige Adresse bleibt gültig und lässt sich einzeln freigeben — sonst würde
|
|
||||||
jedes Umbenennen gedruckte Codes entwerten. Standardmäßig Administratoren
|
|
||||||
vorbehalten, per Einstellung für Bearbeiter freigebbar.
|
|
||||||
|
|
||||||
Schließt [Issue #1](https://git.wilde-server.de/florian.wilde/qrcode-generator/issues/1).
|
|
||||||
|
|
||||||
### Statische Codes benennen und ablegen (20.08.2026)
|
|
||||||
|
|
||||||
Das Namensfeld gilt in beiden Betriebsarten. Es bestimmt den Dateinamen beim
|
|
||||||
Herunterladen und erlaubt, einen statischen Code in „Meine Codes" abzulegen —
|
|
||||||
als Schnappschuss, dessen Inhalt sich nicht mehr ändert.
|
|
||||||
|
|
||||||
Schließt [Issue #2](https://git.wilde-server.de/florian.wilde/qrcode-generator/issues/2).
|
|
||||||
|
|
||||||
### Dokumentation (15.08.2026)
|
|
||||||
|
|
||||||
Bedienungsanleitung mit Bildern, Authentik-Anleitung, Funktionsübersicht.
|
|
||||||
Die Bilder erzeugt ein Skript, damit sie nicht veralten.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+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,84 @@
|
|||||||
|
/**
|
||||||
|
* Schreibt CHANGELOG.md aus src/lib/changelog.ts.
|
||||||
|
*
|
||||||
|
* node scripts/changelog.mjs erzeugen
|
||||||
|
* node scripts/changelog.mjs --check nur prüfen, ob die Datei aktuell ist
|
||||||
|
*
|
||||||
|
* Warum erzeugt und nicht von Hand gepflegt: Die Anwendung zeigt denselben
|
||||||
|
* Verlauf unter /version an. Zwei gepflegte Listen liefen unweigerlich
|
||||||
|
* auseinander — und ein Changelog, der nicht stimmt, ist schlimmer als keiner.
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
const wurzel = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const ziel = join(wurzel, "CHANGELOG.md");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Quelle ist TypeScript, dieses Skript aber schlichtes Node. Statt einen
|
||||||
|
* Übersetzer dazuzunehmen, wird der Datenteil gelesen und ausgewertet — er
|
||||||
|
* besteht ausschließlich aus Zeichenketten und Listen.
|
||||||
|
*/
|
||||||
|
function ladeVerlauf() {
|
||||||
|
const quelle = readFileSync(join(wurzel, "src", "lib", "changelog.ts"), "utf8");
|
||||||
|
const anfang = quelle.indexOf("export const CHANGELOG");
|
||||||
|
if (anfang === -1) throw new Error("CHANGELOG-Liste nicht gefunden.");
|
||||||
|
|
||||||
|
const start = quelle.indexOf("[", anfang);
|
||||||
|
const ende = quelle.indexOf("\n];", start);
|
||||||
|
if (start === -1 || ende === -1) throw new Error("Liste nicht abgrenzbar.");
|
||||||
|
|
||||||
|
const literal = quelle.slice(start, ende + 2);
|
||||||
|
return Function(`"use strict"; return (${literal});`)();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatiereDatum(iso) {
|
||||||
|
const [jahr, monat, tag] = iso.split("-");
|
||||||
|
return `${tag}.${monat}.${jahr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function baue(verlauf) {
|
||||||
|
const zeilen = [
|
||||||
|
"# Änderungen",
|
||||||
|
"",
|
||||||
|
"Was in welcher Fassung dazugekommen ist. Dieselbe Liste steht in der",
|
||||||
|
"Anwendung unter **Version und Änderungen**, erreichbar über die",
|
||||||
|
"Versionsnummer in der Fußzeile.",
|
||||||
|
"",
|
||||||
|
"> Diese Datei wird erzeugt: `npm run changelog`.",
|
||||||
|
"> Gepflegt wird der Verlauf in `src/lib/changelog.ts`.",
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const eintrag of verlauf) {
|
||||||
|
zeilen.push(`## ${eintrag.version} — ${formatiereDatum(eintrag.date)}`, "");
|
||||||
|
zeilen.push(eintrag.summary, "");
|
||||||
|
for (const änderung of eintrag.changes) zeilen.push(`- ${änderung}`);
|
||||||
|
zeilen.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return zeilen.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
const inhalt = baue(ladeVerlauf());
|
||||||
|
|
||||||
|
if (process.argv.includes("--check")) {
|
||||||
|
let vorhanden = "";
|
||||||
|
try {
|
||||||
|
vorhanden = readFileSync(ziel, "utf8");
|
||||||
|
} catch {
|
||||||
|
console.error("CHANGELOG.md fehlt. Erzeugen mit: npm run changelog");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (vorhanden !== inhalt) {
|
||||||
|
console.error(
|
||||||
|
"CHANGELOG.md passt nicht mehr zu src/lib/changelog.ts. Neu erzeugen mit: npm run changelog",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log("CHANGELOG.md ist aktuell.");
|
||||||
|
} else {
|
||||||
|
writeFileSync(ziel, inhalt, "utf8");
|
||||||
|
console.log("CHANGELOG.md geschrieben.");
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { ThemeToggle } from "@/components/theme-toggle";
|
|||||||
import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor";
|
import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor";
|
||||||
import { can } from "@/lib/auth/policy";
|
import { can } from "@/lib/auth/policy";
|
||||||
import { getBranding } from "@/lib/branding";
|
import { getBranding } from "@/lib/branding";
|
||||||
|
import { APP_VERSION } from "@/lib/changelog";
|
||||||
|
|
||||||
// Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert
|
// Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert
|
||||||
// werden.
|
// werden.
|
||||||
@@ -128,9 +129,20 @@ export default async function AppLayout({ children }: LayoutProps<"/">) {
|
|||||||
<main className="flex-1">{children}</main>
|
<main className="flex-1">{children}</main>
|
||||||
|
|
||||||
<footer className="border-t border-slate-200 bg-surface">
|
<footer className="border-t border-slate-200 bg-surface">
|
||||||
<div className="mx-auto max-w-6xl px-4 py-6 text-sm text-slate-500 sm:px-6">
|
<div className="mx-auto flex max-w-6xl flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-4 py-6 text-sm text-slate-500 sm:px-6">
|
||||||
|
<span>
|
||||||
{branding.appName}
|
{branding.appName}
|
||||||
{branding.footerText ? ` · ${branding.footerText}` : null}
|
{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>
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ export default async function MyCodesPage() {
|
|||||||
Meine Codes
|
Meine Codes
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-slate-600">
|
<p className="mt-2 text-slate-600">
|
||||||
Alle dynamischen QR-Codes mit ihrem aktuellen Ziel und der Anzahl der
|
Dynamische Codes mit ihrem aktuellen Ziel und der Anzahl der Scans,
|
||||||
Scans. Statische Codes erscheinen hier nicht — sie werden nicht
|
dazu abgelegte statische Codes. Über die Auswahl „Nach Art filtern“
|
||||||
gespeichert.
|
lässt sich die Liste auf eine der beiden Arten einschränken.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import { Badge, Card } from "@/components/ui";
|
||||||
|
import { requirePageActor } from "@/lib/auth/guard";
|
||||||
|
import {
|
||||||
|
APP_VERSION,
|
||||||
|
CHANGELOG,
|
||||||
|
formatChangelogDate,
|
||||||
|
} from "@/lib/changelog";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Version und Änderungen" };
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function VersionPage() {
|
||||||
|
await requirePageActor();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
|
||||||
|
>
|
||||||
|
<Icon name="arrowLeft" className="size-4" />
|
||||||
|
Zur Typ-Auswahl
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-7">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||||
|
Version und Änderungen
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-slate-600">
|
||||||
|
Hier läuft Version{" "}
|
||||||
|
<span className="font-semibold text-slate-900">{APP_VERSION}</span>.
|
||||||
|
Darunter steht, was in welcher Fassung dazugekommen ist.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
|
Nach einem Update lohnt ein Blick hierher: Steht oben noch die alte
|
||||||
|
Nummer, läuft der Container noch mit dem alten Abbild.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol className="space-y-4">
|
||||||
|
{CHANGELOG.map((eintrag) => {
|
||||||
|
const laeuft = eintrag.version === APP_VERSION;
|
||||||
|
return (
|
||||||
|
<li key={eintrag.version}>
|
||||||
|
<Card>
|
||||||
|
<div className="mb-3 flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">
|
||||||
|
{eintrag.version}
|
||||||
|
</h2>
|
||||||
|
<span className="text-sm text-slate-500 tabular-nums">
|
||||||
|
{formatChangelogDate(eintrag.date)}
|
||||||
|
</span>
|
||||||
|
{laeuft ? (
|
||||||
|
<Badge tone="accent">läuft hier</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mb-3 text-slate-600">{eintrag.summary}</p>
|
||||||
|
|
||||||
|
<ul className="space-y-1.5 text-sm text-slate-700">
|
||||||
|
{eintrag.changes.map((zeile) => (
|
||||||
|
<li key={zeile} className="flex gap-2.5">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="mt-2 size-1.5 shrink-0 rounded-full bg-slate-300"
|
||||||
|
/>
|
||||||
|
<span>{zeile}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -43,6 +43,8 @@ function formatShortDate(value: string | null): string {
|
|||||||
|
|
||||||
type View = "list" | "cards";
|
type View = "list" | "cards";
|
||||||
type SortKey = "neueste" | "aelteste" | "name" | "scans";
|
type SortKey = "neueste" | "aelteste" | "name" | "scans";
|
||||||
|
/** Betriebsart, auf die die Liste eingeschränkt ist. */
|
||||||
|
type ModeFilter = "alle" | "dynamic" | "static";
|
||||||
|
|
||||||
const SORT_LABELS: Record<SortKey, string> = {
|
const SORT_LABELS: Record<SortKey, string> = {
|
||||||
neueste: "Neueste zuerst",
|
neueste: "Neueste zuerst",
|
||||||
@@ -51,6 +53,12 @@ const SORT_LABELS: Record<SortKey, string> = {
|
|||||||
scans: "Meiste Scans",
|
scans: "Meiste Scans",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MODE_LABELS: Record<ModeFilter, string> = {
|
||||||
|
alle: "Alle Arten",
|
||||||
|
dynamic: "Nur dynamische",
|
||||||
|
static: "Nur statische",
|
||||||
|
};
|
||||||
|
|
||||||
/** Merkt sich die gewählte Ansicht über Seitenwechsel hinweg. */
|
/** Merkt sich die gewählte Ansicht über Seitenwechsel hinweg. */
|
||||||
const VIEW_STORAGE_KEY = "qrgen.codes.view";
|
const VIEW_STORAGE_KEY = "qrgen.codes.view";
|
||||||
|
|
||||||
@@ -94,6 +102,7 @@ export function CodesList({
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [sort, setSort] = useState<SortKey>("neueste");
|
const [sort, setSort] = useState<SortKey>("neueste");
|
||||||
const [groupFilter, setGroupFilter] = useState("alle");
|
const [groupFilter, setGroupFilter] = useState("alle");
|
||||||
|
const [modeFilter, setModeFilter] = useState<ModeFilter>("alle");
|
||||||
|
|
||||||
// Erst nach dem Einhängen lesen — auf dem Server gibt es keinen
|
// Erst nach dem Einhängen lesen — auf dem Server gibt es keinen
|
||||||
// localStorage, und ein abweichender Startwert führte zu Hydrationsfehlern.
|
// localStorage, und ein abweichender Startwert führte zu Hydrationsfehlern.
|
||||||
@@ -107,6 +116,14 @@ export function CodesList({
|
|||||||
window.localStorage.setItem(VIEW_STORAGE_KEY, next);
|
window.localStorage.setItem(VIEW_STORAGE_KEY, next);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const changeModeFilter = (next: ModeFilter) => {
|
||||||
|
setModeFilter(next);
|
||||||
|
// Statische Codes zählen keine Scans. Bleibt die Sortierung danach
|
||||||
|
// stehen, sortiert die Liste nach lauter Nullen — und die Auswahl bietet
|
||||||
|
// eine Reihenfolge an, die es dort gar nicht gibt.
|
||||||
|
if (next === "static" && sort === "scans") setSort("neueste");
|
||||||
|
};
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await apiJson<{ codes: QrCodeDto[] }>("/api/codes");
|
const result = await apiJson<{ codes: QrCodeDto[] }>("/api/codes");
|
||||||
@@ -241,11 +258,25 @@ export function CodesList({
|
|||||||
);
|
);
|
||||||
}, [codes]);
|
}, [codes]);
|
||||||
|
|
||||||
|
// Welche Betriebsarten überhaupt vorkommen. Ein Filter, der nichts zu
|
||||||
|
// filtern hat, steht nur im Weg — deshalb erscheint er erst, wenn beide
|
||||||
|
// Arten in der Liste stehen.
|
||||||
|
const kinds = useMemo(() => {
|
||||||
|
let dynamisch = false;
|
||||||
|
let statisch = false;
|
||||||
|
for (const code of codes ?? []) {
|
||||||
|
if (code.mode === "static") statisch = true;
|
||||||
|
else dynamisch = true;
|
||||||
|
}
|
||||||
|
return { dynamisch, statisch, beide: dynamisch && statisch };
|
||||||
|
}, [codes]);
|
||||||
|
|
||||||
const visible = useMemo(() => {
|
const visible = useMemo(() => {
|
||||||
const needle = query.trim().toLowerCase();
|
const needle = query.trim().toLowerCase();
|
||||||
|
|
||||||
const filtered = (codes ?? []).filter((code) => {
|
const filtered = (codes ?? []).filter((code) => {
|
||||||
if (groupFilter !== "alle" && code.groupId !== groupFilter) return false;
|
if (groupFilter !== "alle" && code.groupId !== groupFilter) return false;
|
||||||
|
if (modeFilter !== "alle" && code.mode !== modeFilter) return false;
|
||||||
if (!needle) return true;
|
if (!needle) return true;
|
||||||
return [
|
return [
|
||||||
displayName(code),
|
displayName(code),
|
||||||
@@ -273,7 +304,7 @@ export function CodesList({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
return sorted;
|
return sorted;
|
||||||
}, [codes, query, sort, groupFilter]);
|
}, [codes, query, sort, groupFilter, modeFilter]);
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- View */
|
/* ---------------------------------------------------------------- View */
|
||||||
|
|
||||||
@@ -287,6 +318,10 @@ export function CodesList({
|
|||||||
const svgHref = (code: QrCodeDto) =>
|
const svgHref = (code: QrCodeDto) =>
|
||||||
`/api/codes/${code.id}/qr?format=svg&download=1`;
|
`/api/codes/${code.id}/qr?format=svg&download=1`;
|
||||||
|
|
||||||
|
// Zeigt die Liste ausschließlich statische Codes, hat die Scan-Spalte
|
||||||
|
// nichts zu sagen — dann fällt sie weg, samt der Sortierung danach.
|
||||||
|
const nurStatische = modeFilter === "static" || (kinds.statisch && !kinds.dynamisch);
|
||||||
|
|
||||||
const toolbar = codes.length > 0 && (
|
const toolbar = codes.length > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="relative min-w-0 flex-1 basis-56">
|
<div className="relative min-w-0 flex-1 basis-56">
|
||||||
@@ -320,13 +355,32 @@ export function CodesList({
|
|||||||
</Select>
|
</Select>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{kinds.beide ? (
|
||||||
|
<Select
|
||||||
|
aria-label="Nach Art filtern"
|
||||||
|
className="!w-auto"
|
||||||
|
value={modeFilter}
|
||||||
|
onChange={(event) =>
|
||||||
|
changeModeFilter(event.target.value as ModeFilter)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(Object.keys(MODE_LABELS) as ModeFilter[]).map((key) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{MODE_LABELS[key]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
aria-label="Sortierung"
|
aria-label="Sortierung"
|
||||||
className="!w-auto"
|
className="!w-auto"
|
||||||
value={sort}
|
value={sort}
|
||||||
onChange={(event) => setSort(event.target.value as SortKey)}
|
onChange={(event) => setSort(event.target.value as SortKey)}
|
||||||
>
|
>
|
||||||
{(Object.keys(SORT_LABELS) as SortKey[]).map((key) => (
|
{(Object.keys(SORT_LABELS) as SortKey[])
|
||||||
|
.filter((key) => key !== "scans" || !nurStatische)
|
||||||
|
.map((key) => (
|
||||||
<option key={key} value={key}>
|
<option key={key} value={key}>
|
||||||
{SORT_LABELS[key]}
|
{SORT_LABELS[key]}
|
||||||
</option>
|
</option>
|
||||||
@@ -406,12 +460,13 @@ export function CodesList({
|
|||||||
{codes.length > 0 && visible.length === 0 ? (
|
{codes.length > 0 && visible.length === 0 ? (
|
||||||
<Card className="text-center">
|
<Card className="text-center">
|
||||||
<p className="text-sm text-slate-600">
|
<p className="text-sm text-slate-600">
|
||||||
Kein Code passt zur Suche.{" "}
|
Kein Code passt zu Suche und Filtern.{" "}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setGroupFilter("alle");
|
setGroupFilter("alle");
|
||||||
|
setModeFilter("alle");
|
||||||
}}
|
}}
|
||||||
className="font-medium text-accent-text underline"
|
className="font-medium text-accent-text underline"
|
||||||
>
|
>
|
||||||
@@ -446,9 +501,11 @@ export function CodesList({
|
|||||||
Gruppe
|
Gruppe
|
||||||
</th>
|
</th>
|
||||||
) : null}
|
) : null}
|
||||||
|
{nurStatische ? null : (
|
||||||
<th scope="col" className="w-16 px-3 py-2.5 text-right sm:w-20">
|
<th scope="col" className="w-16 px-3 py-2.5 text-right sm:w-20">
|
||||||
Scans
|
Scans
|
||||||
</th>
|
</th>
|
||||||
|
)}
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
className="hidden w-24 px-3 py-2.5 whitespace-nowrap sm:table-cell"
|
className="hidden w-24 px-3 py-2.5 whitespace-nowrap sm:table-cell"
|
||||||
@@ -571,15 +628,20 @@ export function CodesList({
|
|||||||
</td>
|
</td>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{nurStatische ? 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.mode === "static" ? (
|
{code.mode === "static" ? (
|
||||||
<span className="font-normal text-slate-400" title="Statische Codes werden nicht gezählt">
|
<span
|
||||||
|
className="font-normal text-slate-400"
|
||||||
|
title="Statische Codes werden nicht gezählt"
|
||||||
|
>
|
||||||
—
|
—
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
code.scans
|
code.scans
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
)}
|
||||||
|
|
||||||
<td
|
<td
|
||||||
className="hidden px-3 py-2.5 whitespace-nowrap text-slate-500 tabular-nums sm:table-cell"
|
className="hidden px-3 py-2.5 whitespace-nowrap text-slate-500 tabular-nums sm:table-cell"
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { DomainHilfe } from "@/components/domain-hilfe";
|
||||||
|
import { HelpButton } from "@/components/help-button";
|
||||||
import { Icon } from "@/components/icons";
|
import { Icon } from "@/components/icons";
|
||||||
import { Modal } from "@/components/modal";
|
import { Modal } from "@/components/modal";
|
||||||
import {
|
import {
|
||||||
@@ -134,10 +136,14 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) {
|
|||||||
Eine Domain wirkt erst, wenn dein Reverse Proxy sie auf diese Anwendung
|
Eine Domain wirkt erst, wenn dein Reverse Proxy sie auf diese Anwendung
|
||||||
weiterleitet. Die Weiterleitung erkennt sie am{" "}
|
weiterleitet. Die Weiterleitung erkennt sie am{" "}
|
||||||
<code className="font-mono text-xs">Host</code>-Header und prüft ihn
|
<code className="font-mono text-xs">Host</code>-Header und prüft ihn
|
||||||
gegen diese Liste — ein unbekannter Hostname endet mit 404.
|
gegen diese Liste — ein unbekannter Hostname endet mit 404. Was dafür
|
||||||
|
einzurichten ist, steht in der Kurzhilfe.
|
||||||
</Notice>
|
</Notice>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<HelpButton title="Eine Domain hinzufügen" label="Kurzhilfe">
|
||||||
|
<DomainHilfe baseUrlHostname={baseUrlHostname} />
|
||||||
|
</HelpButton>
|
||||||
<Button type="button" onClick={() => setCreating(true)}>
|
<Button type="button" onClick={() => setCreating(true)}>
|
||||||
Domain registrieren
|
Domain registrieren
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { HelpCode, HelpStep } from "@/components/help-button";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Inhalt der Kurzhilfe auf der Domain-Seite.
|
||||||
|
*
|
||||||
|
* Verdichtet aus dem README-Abschnitt „Hinter einem Reverse Proxy“. Bewusst
|
||||||
|
* nur die Schritte, die diese Anwendung betreffen — wie man im eigenen Proxy
|
||||||
|
* einen Eintrag anlegt, steht in dessen Handbuch besser.
|
||||||
|
*/
|
||||||
|
export function DomainHilfe({ baseUrlHostname }: { baseUrlHostname: string }) {
|
||||||
|
const beispiel = baseUrlHostname || "qr.example.com";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<p>
|
||||||
|
Eine Domain hier zu registrieren genügt nicht — sie muss den Browser
|
||||||
|
auch hierher führen. Dazu gehören drei Dinge außerhalb dieser
|
||||||
|
Anwendung: ein DNS-Eintrag, ein Eintrag im Reverse Proxy und ein
|
||||||
|
Zertifikat.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ol className="space-y-4">
|
||||||
|
<HelpStep n={1} title="DNS-Eintrag anlegen">
|
||||||
|
<p>
|
||||||
|
Beim Anbieter der Domain einen <strong>A-Record</strong> (bei IPv6
|
||||||
|
zusätzlich <strong>AAAA</strong>) auf die öffentliche Adresse des
|
||||||
|
Reverse Proxy setzen. Ein CNAME auf einen bestehenden Namen tut es
|
||||||
|
auch.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Steht der Name nur im internen DNS oder ist er nur über VPN
|
||||||
|
erreichbar, funktionieren gedruckte Codes{" "}
|
||||||
|
<strong>auch nur dort</strong>. Für Aushänge, Flyer und
|
||||||
|
Verpackungen muss er öffentlich auflösbar sein.
|
||||||
|
</p>
|
||||||
|
</HelpStep>
|
||||||
|
|
||||||
|
<HelpStep n={2} title="Im Reverse Proxy weiterleiten">
|
||||||
|
<p>
|
||||||
|
Für die Domain einen eigenen Eintrag anlegen, der auf diese
|
||||||
|
Anwendung zeigt — Ziel ist der Docker-Host auf Port{" "}
|
||||||
|
<code className="font-mono text-xs">3000</code>, oder im selben
|
||||||
|
Docker-Netz der Servicename{" "}
|
||||||
|
<code className="font-mono text-xs">app</code>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Diese drei Kopfzeilen muss der Proxy mitschicken. Nginx Proxy
|
||||||
|
Manager, Traefik und Caddy tun das von sich aus; ein blankes nginx
|
||||||
|
braucht sie im Serverblock:
|
||||||
|
</p>
|
||||||
|
<HelpCode>{`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;`}</HelpCode>
|
||||||
|
<p>
|
||||||
|
Ohne <code className="font-mono text-xs">X-Forwarded-Host</code>{" "}
|
||||||
|
landet jeder Aufruf unter der Standard-Domain, und Kurz-URLs der
|
||||||
|
Zweitdomain werden nicht gefunden.
|
||||||
|
</p>
|
||||||
|
</HelpStep>
|
||||||
|
|
||||||
|
<HelpStep n={3} title="Zertifikat anfordern">
|
||||||
|
<p>
|
||||||
|
HTTPS erledigt der Proxy. Im Nginx Proxy Manager ist das der Reiter{" "}
|
||||||
|
<em>SSL</em> mit <em>Force SSL</em>; Traefik und Caddy holen das
|
||||||
|
Zertifikat selbst, sobald der DNS-Eintrag steht.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Ohne gültiges Zertifikat brechen viele Scanner-Apps den Aufruf ohne
|
||||||
|
Erklärung ab — der Code sieht dann für die Nutzer schlicht kaputt
|
||||||
|
aus.
|
||||||
|
</p>
|
||||||
|
</HelpStep>
|
||||||
|
|
||||||
|
<HelpStep n={4} title="Domain hier registrieren">
|
||||||
|
<p>
|
||||||
|
Über <strong>Domain registrieren</strong> den Hostnamen eintragen —
|
||||||
|
genau so, wie er im Browser steht, ohne{" "}
|
||||||
|
<code className="font-mono text-xs">https://</code> und ohne Pfad:
|
||||||
|
</p>
|
||||||
|
<HelpCode>{beispiel}</HelpCode>
|
||||||
|
<p>
|
||||||
|
Die Weiterleitung prüft den Hostnamen gegen diese Liste. Ein
|
||||||
|
unbekannter Name endet mit 404 — über eine gefälschte Kopfzeile
|
||||||
|
lässt sich also keine fremde Domain ansprechen.
|
||||||
|
</p>
|
||||||
|
</HelpStep>
|
||||||
|
|
||||||
|
<HelpStep n={5} title="Einer Gruppe zuteilen">
|
||||||
|
<p>
|
||||||
|
Eine frisch registrierte Domain gehört noch niemandem. Erst über{" "}
|
||||||
|
<strong>Gruppen zuteilen</strong> können Mitglieder dieser Gruppe
|
||||||
|
dort Codes anlegen. Die als <em>Voreinstellung</em> markierte
|
||||||
|
Domain ist die, die beim Anlegen vorausgewählt ist.
|
||||||
|
</p>
|
||||||
|
</HelpStep>
|
||||||
|
|
||||||
|
<HelpStep n={6} title="Nachprüfen">
|
||||||
|
<p>Von außerhalb des eigenen Netzes:</p>
|
||||||
|
<HelpCode>{`curl -I https://${beispiel}/`}</HelpCode>
|
||||||
|
<p>
|
||||||
|
Kommt eine Antwort dieser Anwendung, stimmen DNS, Proxy und
|
||||||
|
Zertifikat. Danach einen Code der zugeteilten Gruppe anlegen, die
|
||||||
|
Domain auswählen und den fertigen Code einmal mit dem Handy
|
||||||
|
scannen — das ist die einzige Prüfung, die wirklich zählt.
|
||||||
|
</p>
|
||||||
|
</HelpStep>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||||
|
<h3 className="font-semibold text-slate-900">
|
||||||
|
Was <code className="font-mono text-xs">BASE_URL</code> damit zu tun hat
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-slate-600">
|
||||||
|
Die Umgebungsvariable{" "}
|
||||||
|
<code className="font-mono text-xs">BASE_URL</code> ist die
|
||||||
|
Standard-Adresse: Sie greift bei Aufrufen über eine nicht
|
||||||
|
registrierte Adresse und bestimmt, ob Kurz-URLs mit{" "}
|
||||||
|
<code className="font-mono text-xs">http</code> oder{" "}
|
||||||
|
<code className="font-mono text-xs">https</code> gebildet werden.
|
||||||
|
Weitere Domains werden hier registriert, nicht dort eingetragen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import { Modal } from "@/components/modal";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kurzhilfe: ein Fragezeichen neben einer Überschrift, das eine Erklärung
|
||||||
|
* aufklappt.
|
||||||
|
*
|
||||||
|
* Bewusst ein Dialog und kein Dauertext auf der Seite — die Erklärung braucht
|
||||||
|
* man einmal beim Einrichten und danach nie wieder. Sie ständig zu zeigen
|
||||||
|
* würde die Seite für alle anderen Aufrufe zumüllen.
|
||||||
|
*/
|
||||||
|
export function HelpButton({
|
||||||
|
title,
|
||||||
|
label = "Hilfe",
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
/** Überschrift des Dialogs. */
|
||||||
|
title: string;
|
||||||
|
/** Beschriftung des Knopfes. */
|
||||||
|
label?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
>
|
||||||
|
<Icon name="help" className="size-4" />
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
size="lg"
|
||||||
|
title={title}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
footer={
|
||||||
|
<Button type="button" onClick={() => setOpen(false)}>
|
||||||
|
Verstanden
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="max-h-[65vh] overflow-y-auto pr-1 text-sm text-slate-700">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------- Bausteine für den Inhalt */
|
||||||
|
|
||||||
|
/** Ein nummerierter Schritt mit Überschrift. */
|
||||||
|
export function HelpStep({
|
||||||
|
n,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
n: number;
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<li className="flex gap-3">
|
||||||
|
<span className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-accent-soft text-xs font-semibold text-accent-soft-fg">
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="font-semibold text-slate-900">{title}</h3>
|
||||||
|
<div className="mt-1 space-y-2 text-slate-600">{children}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Befehl oder Wert zum Abtippen — auf schmalen Schirmen scrollbar. */
|
||||||
|
export function HelpCode({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<pre className="overflow-x-auto rounded-lg bg-slate-100 px-3 py-2 font-mono text-xs text-slate-800">
|
||||||
|
{children}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -238,6 +238,13 @@ const PATHS = {
|
|||||||
<path d="M8 10.5V7.5a4 4 0 0 1 8 0v3" />
|
<path d="M8 10.5V7.5a4 4 0 0 1 8 0v3" />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
help: (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M9.5 9.5a2.5 2.5 0 1 1 3.2 2.4c-.6.2-.95.75-.95 1.4v.45" />
|
||||||
|
<path d="M11.75 16.75h.01" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type IconName = keyof typeof PATHS;
|
export type IconName = keyof typeof PATHS;
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ export function Modal({
|
|||||||
onClose,
|
onClose,
|
||||||
children,
|
children,
|
||||||
footer,
|
footer,
|
||||||
|
size = "md",
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
|
/** `lg` für längere Texte wie eine Kurzhilfe. */
|
||||||
|
size?: "md" | "lg";
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDialogElement>(null);
|
const ref = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
@@ -39,7 +42,9 @@ 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-surface p-0 shadow-xl backdrop:bg-scrim/50"
|
className={`m-auto w-[calc(100%-2rem)] ${
|
||||||
|
size === "lg" ? "max-w-2xl" : "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>
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user