diff --git a/README.md b/README.md index c1f750d..c28dee3 100644 --- a/README.md +++ b/README.md @@ -642,13 +642,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/docs/funktionen.md b/docs/funktionen.md index 1662174..6305123 100644 --- a/docs/funktionen.md +++ b/docs/funktionen.md @@ -122,6 +122,17 @@ 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. +**Dublettenprüfung.** Liegt in derselben Gruppe schon ein statischer Code mit +identischem Inhalt, hält das Ablegen an und zeigt, welcher Eintrag das ist und +wann er entstand. Der Knopf heißt dann **Trotzdem ablegen** — eine Warnung, +keine Sperre: Zwei Fassungen desselben Codes können gewollt sein. + +Verglichen wird nur der Inhalt, nicht die Gestaltung. Derselbe Code in +Schwarz-Weiß und in Hausfarben gilt also als Dublette — was er streng +genommen auch ist, denn gescannt wird beides Mal dasselbe. Dynamische Codes +bleiben außen vor: Zwei Kurz-Links auf dasselbe Ziel sind der Normalfall, +sobald zwei Kampagnen getrennt gezählt werden sollen. + ### Der Kurz-Link Ohne eigene Angabe entsteht er aus acht Zeichen eines Alphabets ohne 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/app/api/codes/route.ts b/src/app/api/codes/route.ts index 239279e..d87cbea 100644 --- a/src/app/api/codes/route.ts +++ b/src/app/api/codes/route.ts @@ -7,6 +7,7 @@ import { requirePermission, visibleGroupIds } from "@/lib/auth/policy"; import { toJsonInput, toQrCodeDto } from "@/lib/codes"; import { prisma } from "@/lib/db"; import { resolveTargetDomain } from "@/lib/domains"; +import { findStaticDuplicate } from "@/lib/duplicates"; import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; import { requireAvailableQrType } from "@/lib/qr/availability"; import { designSchema } from "@/lib/qr/design"; @@ -43,6 +44,12 @@ const createSchema = z.object({ groupId: z.string().trim().min(1).optional(), /** Unter welcher Domain die Kurz-URL entsteht. */ domainId: z.string().trim().min(1).optional(), + /** + * Eine gemeldete Dublette bewusst in Kauf nehmen. Ohne dieses Häkchen + * antwortet das Anlegen mit 409, sobald derselbe Inhalt schon als + * statischer Code in der Gruppe liegt. + */ + allowDuplicate: z.boolean().default(false), }); function isUniqueViolation(error: unknown): boolean { @@ -140,6 +147,24 @@ export async function POST(request: NextRequest): Promise { await assertSlugAvailable(domainId, gewuenscht); } + // Zweimal derselbe statische Inhalt ergibt zweimal dasselbe Bild. Das ist + // erlaubt, aber selten gewollt — deshalb ein Halt mit Rückfrage statt + // eines stillen zweiten Eintrags. + if (istStatisch && !parsed.data.allowDuplicate) { + const dublette = await findStaticDuplicate(groupId, qrType.id, validated.data); + if (dublette) { + return Response.json( + { + error: dublette.label + ? `Diesen Inhalt hast du schon abgelegt — als „${dublette.label}“.` + : "Diesen Inhalt hast du schon abgelegt.", + duplicate: dublette, + }, + { status: 409 }, + ); + } + } + if (istStatisch) { const created = await prisma.qrCode.create({ data: { 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.{" "} 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}

    diff --git a/src/components/qr-generator.tsx b/src/components/qr-generator.tsx index 5f1e9f3..d3af378 100644 --- a/src/components/qr-generator.tsx +++ b/src/components/qr-generator.tsx @@ -43,6 +43,21 @@ function sameData(a: unknown, b: unknown): boolean { return stableStringify(a) === stableStringify(b); } +/** Der bereits abgelegte Code, den die Dublettenprüfung gemeldet hat. */ +type Dublette = { id: string; label: string | null; createdAt: string }; + +/** Liest die Meldung aus einer 409-Antwort — oder gibt auf. */ +function leseDublette(details: unknown): Dublette | null { + if (details === null || typeof details !== "object") return null; + const feld = (details as { duplicate?: unknown }).duplicate; + if (feld === null || typeof feld !== "object") return null; + const { id, label, createdAt } = feld as Record; + if (typeof id !== "string" || typeof createdAt !== "string") return null; + return { id, label: typeof label === "string" ? label : null, createdAt }; +} + +const dublettenDatum = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }); + export function QrGenerator({ typeId, /** Darf der Kurz-Link selbst vergeben werden? Sonst erscheint das Feld nicht. */ @@ -65,6 +80,14 @@ export function QrGenerator({ const [saved, setSaved] = useState(null); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); + /** + * Gemeldete Dublette samt dem Inhalt, für den sie gilt. Ändert sich der + * Inhalt, ist die Warnung überholt und verschwindet von selbst — ohne + * einen Effekt, der sie hinterherräumt. + */ + const [duplicate, setDuplicate] = useState< + { treffer: Dublette; inhalt: string } | null + >(null); const [copied, setCopied] = useState(false); const [preview, setPreview] = useState(null); @@ -187,10 +210,18 @@ export function QrGenerator({ !sameData(saved.design, design) || (saved.label ?? "") !== label.trim()); - const handleSave = useCallback(async () => { + // Nur zeigen, solange der Inhalt derselbe ist, für den die Meldung kam. + const dublette = + duplicate !== null && + duplicate.inhalt === stableStringify(validation.ok ? validation.data : data) + ? duplicate.treffer + : null; + + const handleSave = useCallback(async (trotzDublette = false) => { if (!validation.ok || !designValid || slugProblem) return; setSaving(true); setSaveError(null); + setDuplicate(null); try { const body = { type: typeId, @@ -200,6 +231,8 @@ export function QrGenerator({ label: label.trim() || undefined, // Ein statischer Code hat keinen Kurz-Link. slug: isDynamic ? wunschSlug.trim() || undefined : undefined, + // Nur beim zweiten Anlauf, nachdem die Warnung dastand. + allowDuplicate: trotzDublette, }; const result = saved ? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, { @@ -212,11 +245,20 @@ export function QrGenerator({ }); setSaved(result.code); } catch (error) { - setSaveError( - error instanceof ApiError - ? error.message - : "Der Code konnte nicht gespeichert werden.", - ); + // Eine gemeldete Dublette ist kein Fehler, sondern eine Rückfrage. + const gemeldet = + error instanceof ApiError && error.status === 409 + ? leseDublette(error.details) + : null; + if (gemeldet) { + setDuplicate({ treffer: gemeldet, inhalt: stableStringify(validation.data) }); + } else { + setSaveError( + error instanceof ApiError + ? error.message + : "Der Code konnte nicht gespeichert werden.", + ); + } } finally { setSaving(false); } @@ -232,6 +274,7 @@ export function QrGenerator({ setIsDynamic(dynamisch); setSaved(null); setSaveError(null); + setDuplicate(null); }, []); const handleCopy = useCallback(async () => { @@ -423,14 +466,43 @@ export function QrGenerator({
    ) : ( - +
    + {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 ? (