From 1cf1df54df9bc3e818e885e915b6e429bcfde04f Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 21 Aug 2026 02:36:18 +0200 Subject: [PATCH 1/3] Kurzhilfe erklaert das Hinzufuegen einer Domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eine Domain zu registrieren genuegt nicht — DNS, Reverse Proxy und Zertifikat muessen dazu passen. Bislang stand das nur im README, also genau dort nicht, wo man es braucht. Auf der Domain-Seite oeffnet ein Knopf die Erklaerung in sechs Schritten, mit den drei noetigen Kopfzeilen und einem Pruefbefehl. Der Knopf selbst ist als HelpButton allgemein gehalten, damit andere Verwaltungsseiten spaeter dieselbe Form bekommen koennen. Refs #4 --- src/components/domain-admin.tsx | 10 ++- src/components/domain-hilfe.tsx | 128 ++++++++++++++++++++++++++++++++ src/components/help-button.tsx | 94 +++++++++++++++++++++++ src/components/icons.tsx | 7 ++ src/components/modal.tsx | 7 +- 5 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 src/components/domain-hilfe.tsx create mode 100644 src/components/help-button.tsx diff --git a/src/components/domain-admin.tsx b/src/components/domain-admin.tsx index e10e403..9868d87 100644 --- a/src/components/domain-admin.tsx +++ b/src/components/domain-admin.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useState } from "react"; +import { DomainHilfe } from "@/components/domain-hilfe"; +import { HelpButton } from "@/components/help-button"; import { Icon } from "@/components/icons"; import { Modal } from "@/components/modal"; import { @@ -134,10 +136,14 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) { Eine Domain wirkt erst, wenn dein Reverse Proxy sie auf diese Anwendung weiterleitet. Die Weiterleitung erkennt sie am{" "} Host-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. -
+
+ + + diff --git a/src/components/domain-hilfe.tsx b/src/components/domain-hilfe.tsx new file mode 100644 index 0000000..11c19e7 --- /dev/null +++ b/src/components/domain-hilfe.tsx @@ -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 ( +
+

+ 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. +

+ +
    + +

    + Beim Anbieter der Domain einen A-Record (bei IPv6 + zusätzlich AAAA) auf die öffentliche Adresse des + Reverse Proxy setzen. Ein CNAME auf einen bestehenden Namen tut es + auch. +

    +

    + Steht der Name nur im internen DNS oder ist er nur über VPN + erreichbar, funktionieren gedruckte Codes{" "} + auch nur dort. Für Aushänge, Flyer und + Verpackungen muss er öffentlich auflösbar sein. +

    +
    + + +

    + Für die Domain einen eigenen Eintrag anlegen, der auf diese + Anwendung zeigt — Ziel ist der Docker-Host auf Port{" "} + 3000, oder im selben + Docker-Netz der Servicename{" "} + app. +

    +

    + 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: +

    + {`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;`} +

    + Ohne X-Forwarded-Host{" "} + landet jeder Aufruf unter der Standard-Domain, und Kurz-URLs der + Zweitdomain werden nicht gefunden. +

    +
    + + +

    + HTTPS erledigt der Proxy. Im Nginx Proxy Manager ist das der Reiter{" "} + SSL mit Force SSL; Traefik und Caddy holen das + Zertifikat selbst, sobald der DNS-Eintrag steht. +

    +

    + 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. +

    +
    + + +

    + Über Domain registrieren den Hostnamen eintragen — + genau so, wie er im Browser steht, ohne{" "} + https:// und ohne Pfad: +

    + {beispiel} +

    + 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. +

    +
    + + +

    + Eine frisch registrierte Domain gehört noch niemandem. Erst über{" "} + Gruppen zuteilen können Mitglieder dieser Gruppe + dort Codes anlegen. Die als Voreinstellung markierte + Domain ist die, die beim Anlegen vorausgewählt ist. +

    +
    + + +

    Von außerhalb des eigenen Netzes:

    + {`curl -I https://${beispiel}/`} +

    + 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. +

    +
    +
+ +
+

+ Was BASE_URL damit zu tun hat +

+

+ Die Umgebungsvariable{" "} + BASE_URL ist die + Standard-Adresse: Sie greift bei Aufrufen über eine nicht + registrierte Adresse und bestimmt, ob Kurz-URLs mit{" "} + http oder{" "} + https gebildet werden. + Weitere Domains werden hier registriert, nicht dort eingetragen. +

+
+
+ ); +} diff --git a/src/components/help-button.tsx b/src/components/help-button.tsx new file mode 100644 index 0000000..fd9cdb6 --- /dev/null +++ b/src/components/help-button.tsx @@ -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 ( + <> + + + setOpen(false)} + footer={ + + } + > +
+ {children} +
+
+ + ); +} + +/* ------------------------------------------------- Bausteine für den Inhalt */ + +/** Ein nummerierter Schritt mit Überschrift. */ +export function HelpStep({ + n, + title, + children, +}: { + n: number; + title: string; + children: ReactNode; +}) { + return ( +
  • + + {n} + +
    +

    {title}

    +
    {children}
    +
    +
  • + ); +} + +/** Ein Befehl oder Wert zum Abtippen — auf schmalen Schirmen scrollbar. */ +export function HelpCode({ children }: { children: ReactNode }) { + return ( +
    +      {children}
    +    
    + ); +} diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 7ab0f02..91d02c8 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -238,6 +238,13 @@ const PATHS = { ), + help: ( + <> + + + + + ), } as const; export type IconName = keyof typeof PATHS; diff --git a/src/components/modal.tsx b/src/components/modal.tsx index d72150e..63caed7 100644 --- a/src/components/modal.tsx +++ b/src/components/modal.tsx @@ -9,12 +9,15 @@ export function Modal({ onClose, children, footer, + size = "md", }: { open: boolean; title: string; onClose: () => void; children: ReactNode; footer?: ReactNode; + /** `lg` für längere Texte wie eine Kurzhilfe. */ + size?: "md" | "lg"; }) { const ref = useRef(null); @@ -39,7 +42,9 @@ export function Modal({ // Klick auf die Hintergrundfläche des Dialogs schließt ihn. 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`} >

    {title}

    From 142536140cfc8ceb4105008f42d8feb30a5f7901 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 21 Aug 2026 02:43:13 +0200 Subject: [PATCH 2/3] Meine Codes: Liste nach Art einschraenken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seit statische Codes ablegbar sind, mischen sich zwei Arten in derselben Liste. Eine Auswahl schraenkt sie auf dynamische oder statische ein — und erscheint nur, wenn ueberhaupt beide vorkommen. Zeigt die Liste nur statische Codes, faellt die Scan-Spalte weg und die Sortierung nach Scans mit ihr: Statische zaehlen keine Scans, die Spalte waere eine Reihe aus Strichen. Stand die Sortierung darauf, springt sie auf "Neueste zuerst". Die Beschreibung der Seite behauptete noch, statische Codes wuerden nicht gespeichert. Das stimmt seit #2 nicht mehr. Refs #5 --- README.md | 9 ++- src/app/(app)/meine-codes/page.tsx | 6 +- src/components/codes-list.tsx | 100 +++++++++++++++++++++++------ 3 files changed, 91 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 86826ca..e6e6d2c 100644 --- a/README.md +++ b/README.md @@ -618,13 +618,18 @@ Dazu kommen - ein **Suchfeld** über Name, Ziel, Kurz-URL und Typ, - 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 Kurz-URL rückt dann unter den Namen. Zum Herunterladen dient dort die 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 Auswertung: Durchschnitt, Verteilung der Noten und die abgegebenen Kommentare. diff --git a/src/app/(app)/meine-codes/page.tsx b/src/app/(app)/meine-codes/page.tsx index 26bcf7d..12357a9 100644 --- a/src/app/(app)/meine-codes/page.tsx +++ b/src/app/(app)/meine-codes/page.tsx @@ -27,9 +27,9 @@ export default async function MyCodesPage() { Meine Codes

    - Alle dynamischen QR-Codes mit ihrem aktuellen Ziel und der Anzahl der - Scans. Statische Codes erscheinen hier nicht — sie werden nicht - gespeichert. + Dynamische Codes mit ihrem aktuellen Ziel und der Anzahl der Scans, + dazu abgelegte statische Codes. Über die Auswahl „Nach Art filtern“ + lässt sich die Liste auf eine der beiden Arten einschränken.

    diff --git a/src/components/codes-list.tsx b/src/components/codes-list.tsx index 5ab8b16..b7e94b9 100644 --- a/src/components/codes-list.tsx +++ b/src/components/codes-list.tsx @@ -43,6 +43,8 @@ function formatShortDate(value: string | null): string { type View = "list" | "cards"; type SortKey = "neueste" | "aelteste" | "name" | "scans"; +/** Betriebsart, auf die die Liste eingeschränkt ist. */ +type ModeFilter = "alle" | "dynamic" | "static"; const SORT_LABELS: Record = { neueste: "Neueste zuerst", @@ -51,6 +53,12 @@ const SORT_LABELS: Record = { scans: "Meiste Scans", }; +const MODE_LABELS: Record = { + alle: "Alle Arten", + dynamic: "Nur dynamische", + static: "Nur statische", +}; + /** Merkt sich die gewählte Ansicht über Seitenwechsel hinweg. */ const VIEW_STORAGE_KEY = "qrgen.codes.view"; @@ -94,6 +102,7 @@ export function CodesList({ const [query, setQuery] = useState(""); const [sort, setSort] = useState("neueste"); const [groupFilter, setGroupFilter] = useState("alle"); + const [modeFilter, setModeFilter] = useState("alle"); // Erst nach dem Einhängen lesen — auf dem Server gibt es keinen // localStorage, und ein abweichender Startwert führte zu Hydrationsfehlern. @@ -107,6 +116,14 @@ export function CodesList({ 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 () => { try { const result = await apiJson<{ codes: QrCodeDto[] }>("/api/codes"); @@ -241,11 +258,25 @@ export function CodesList({ ); }, [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 needle = query.trim().toLowerCase(); const filtered = (codes ?? []).filter((code) => { if (groupFilter !== "alle" && code.groupId !== groupFilter) return false; + if (modeFilter !== "alle" && code.mode !== modeFilter) return false; if (!needle) return true; return [ displayName(code), @@ -273,7 +304,7 @@ export function CodesList({ } }); return sorted; - }, [codes, query, sort, groupFilter]); + }, [codes, query, sort, groupFilter, modeFilter]); /* ---------------------------------------------------------------- View */ @@ -287,6 +318,10 @@ export function CodesList({ const svgHref = (code: QrCodeDto) => `/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 && (
    @@ -320,17 +355,36 @@ export function CodesList({ ) : null} + {kinds.beide ? ( + + ) : null} +
    0 && visible.length === 0 ? (

    - Kein Code passt zur Suche.{" "} + Kein Code passt zu Suche und Filtern.{" "}

    ) : ( - +
    + {dublette ? ( + + + Diesen Inhalt hast du schon abgelegt + {" "} + — {dublette.label ? `„${dublette.label}“` : "ohne Namen"}{" "} + vom {dublettenDatum.format(new Date(dublette.createdAt))}. + Zweimal derselbe Inhalt ergibt zweimal denselben Code. + + Verglichen wird nur der Inhalt, nicht die Gestaltung — + derselbe Code in anderen Farben gilt hier als Dublette. + + + + Den vorhandenen ansehen + + + + + ) : null} + +
    ) ) : saved ? (
    @@ -460,7 +532,7 @@ export function QrGenerator({ {hasUnsavedChanges ? (