Feat #7: Domain-Check für DNS, Webserver und Zertifikat #15
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,9 +27,9 @@ export default async function MyCodesPage() {
|
||||
Meine Codes
|
||||
</h1>
|
||||
<p className="mt-2 text-slate-600">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<Response> {
|
||||
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: {
|
||||
|
||||
@@ -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<SortKey, string> = {
|
||||
neueste: "Neueste zuerst",
|
||||
@@ -51,6 +53,12 @@ const SORT_LABELS: Record<SortKey, string> = {
|
||||
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. */
|
||||
const VIEW_STORAGE_KEY = "qrgen.codes.view";
|
||||
|
||||
@@ -94,6 +102,7 @@ export function CodesList({
|
||||
const [query, setQuery] = useState("");
|
||||
const [sort, setSort] = useState<SortKey>("neueste");
|
||||
const [groupFilter, setGroupFilter] = useState("alle");
|
||||
const [modeFilter, setModeFilter] = useState<ModeFilter>("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 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-0 flex-1 basis-56">
|
||||
@@ -320,17 +355,36 @@ export function CodesList({
|
||||
</Select>
|
||||
) : 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
|
||||
aria-label="Sortierung"
|
||||
className="!w-auto"
|
||||
value={sort}
|
||||
onChange={(event) => setSort(event.target.value as SortKey)}
|
||||
>
|
||||
{(Object.keys(SORT_LABELS) as SortKey[]).map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{SORT_LABELS[key]}
|
||||
</option>
|
||||
))}
|
||||
{(Object.keys(SORT_LABELS) as SortKey[])
|
||||
.filter((key) => key !== "scans" || !nurStatische)
|
||||
.map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{SORT_LABELS[key]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<div
|
||||
@@ -406,12 +460,13 @@ export function CodesList({
|
||||
{codes.length > 0 && visible.length === 0 ? (
|
||||
<Card className="text-center">
|
||||
<p className="text-sm text-slate-600">
|
||||
Kein Code passt zur Suche.{" "}
|
||||
Kein Code passt zu Suche und Filtern.{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setGroupFilter("alle");
|
||||
setModeFilter("alle");
|
||||
}}
|
||||
className="font-medium text-accent-text underline"
|
||||
>
|
||||
@@ -446,9 +501,11 @@ export function CodesList({
|
||||
Gruppe
|
||||
</th>
|
||||
) : null}
|
||||
<th scope="col" className="w-16 px-3 py-2.5 text-right sm:w-20">
|
||||
Scans
|
||||
</th>
|
||||
{nurStatische ? null : (
|
||||
<th scope="col" className="w-16 px-3 py-2.5 text-right sm:w-20">
|
||||
Scans
|
||||
</th>
|
||||
)}
|
||||
<th
|
||||
scope="col"
|
||||
className="hidden w-24 px-3 py-2.5 whitespace-nowrap sm:table-cell"
|
||||
@@ -571,15 +628,20 @@ export function CodesList({
|
||||
</td>
|
||||
) : null}
|
||||
|
||||
<td className="px-3 py-2.5 text-right font-semibold text-slate-900 tabular-nums">
|
||||
{code.mode === "static" ? (
|
||||
<span className="font-normal text-slate-400" title="Statische Codes werden nicht gezählt">
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
code.scans
|
||||
)}
|
||||
</td>
|
||||
{nurStatische ? null : (
|
||||
<td className="px-3 py-2.5 text-right font-semibold text-slate-900 tabular-nums">
|
||||
{code.mode === "static" ? (
|
||||
<span
|
||||
className="font-normal text-slate-400"
|
||||
title="Statische Codes werden nicht gezählt"
|
||||
>
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
code.scans
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
|
||||
<td
|
||||
className="hidden px-3 py-2.5 whitespace-nowrap text-slate-500 tabular-nums sm:table-cell"
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { DomainBericht } from "@/components/domain-bericht";
|
||||
import { DomainHilfe } from "@/components/domain-hilfe";
|
||||
import { HelpButton } from "@/components/help-button";
|
||||
import { Icon } from "@/components/icons";
|
||||
import { Modal } from "@/components/modal";
|
||||
import {
|
||||
@@ -160,10 +162,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{" "}
|
||||
<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>
|
||||
|
||||
<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)}>
|
||||
Domain registrieren
|
||||
</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" />
|
||||
</>
|
||||
),
|
||||
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;
|
||||
|
||||
export type IconName = keyof typeof PATHS;
|
||||
|
||||
@@ -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<HTMLDialogElement>(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`}
|
||||
>
|
||||
<div className="p-5 sm:p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<QrCodeDto | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(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<PreviewState | null>(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({
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !ready}
|
||||
>
|
||||
{saving ? "Legt ab …" : "In „Meine Codes“ ablegen"}
|
||||
</Button>
|
||||
<div className="space-y-3">
|
||||
{dublette ? (
|
||||
<Notice tone="warning">
|
||||
<span className="font-medium">
|
||||
Diesen Inhalt hast du schon abgelegt
|
||||
</span>{" "}
|
||||
— {dublette.label ? `„${dublette.label}“` : "ohne Namen"}{" "}
|
||||
vom {dublettenDatum.format(new Date(dublette.createdAt))}.
|
||||
Zweimal derselbe Inhalt ergibt zweimal denselben Code.
|
||||
<span className="mt-1 block text-xs">
|
||||
Verglichen wird nur der Inhalt, nicht die Gestaltung —
|
||||
derselbe Code in anderen Farben gilt hier als Dublette.
|
||||
</span>
|
||||
<span className="mt-2 flex flex-wrap items-center gap-3">
|
||||
<Link
|
||||
href="/meine-codes"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg text-sm font-medium underline"
|
||||
>
|
||||
Den vorhandenen ansehen
|
||||
<Icon name="external" className="size-3.5" />
|
||||
</Link>
|
||||
</span>
|
||||
</Notice>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => void handleSave(dublette !== null)}
|
||||
disabled={saving || !ready}
|
||||
>
|
||||
{saving
|
||||
? "Legt ab …"
|
||||
: dublette
|
||||
? "Trotzdem ablegen"
|
||||
: "In „Meine Codes“ ablegen"}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
) : saved ? (
|
||||
<div className="space-y-3">
|
||||
@@ -460,7 +532,7 @@ export function QrGenerator({
|
||||
{hasUnsavedChanges ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving || !ready}
|
||||
>
|
||||
{saving ? "Speichert …" : "Änderungen speichern"}
|
||||
@@ -492,7 +564,7 @@ export function QrGenerator({
|
||||
</Notice>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving || !ready || Boolean(slugProblem)}
|
||||
>
|
||||
{saving ? "Speichert …" : "Dynamischen Code speichern"}
|
||||
|
||||
+33
-14
@@ -1,23 +1,42 @@
|
||||
/** Kleine Hilfen für Aufrufe der eigenen API aus Client-Komponenten. */
|
||||
|
||||
async function readError(response: Response): Promise<string> {
|
||||
async function readError(response: Response): Promise<ApiError> {
|
||||
let body: unknown = null;
|
||||
try {
|
||||
const body: unknown = await response.json();
|
||||
if (
|
||||
body !== null &&
|
||||
typeof body === "object" &&
|
||||
"error" in body &&
|
||||
typeof (body as { error: unknown }).error === "string"
|
||||
) {
|
||||
return (body as { error: string }).error;
|
||||
}
|
||||
body = await response.json();
|
||||
} catch {
|
||||
// Antwort ohne JSON-Körper — unten kommt die allgemeine Meldung.
|
||||
}
|
||||
return `Die Anfrage ist fehlgeschlagen (HTTP ${response.status}).`;
|
||||
|
||||
const message =
|
||||
body !== null &&
|
||||
typeof body === "object" &&
|
||||
"error" in body &&
|
||||
typeof (body as { error: unknown }).error === "string"
|
||||
? (body as { error: string }).error
|
||||
: `Die Anfrage ist fehlgeschlagen (HTTP ${response.status}).`;
|
||||
|
||||
return new ApiError(message, response.status, body);
|
||||
}
|
||||
|
||||
export class ApiError extends Error {}
|
||||
/**
|
||||
* Fehler einer eigenen API-Antwort.
|
||||
*
|
||||
* Trägt neben der Meldung auch den Status und den Antwortkörper: Manche
|
||||
* Fehler sind keine Sackgasse, sondern eine Rückfrage — etwa die gemeldete
|
||||
* Dublette beim Ablegen, zu der die Oberfläche den vorhandenen Eintrag
|
||||
* anzeigen will.
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly details: unknown;
|
||||
|
||||
constructor(message: string, status = 0, details: unknown = null) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiJson<T>(
|
||||
url: string,
|
||||
@@ -34,7 +53,7 @@ export async function apiJson<T>(
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(await readError(response));
|
||||
throw await readError(response);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
@@ -55,7 +74,7 @@ export async function downloadFile(
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(await readError(response));
|
||||
throw await readError(response);
|
||||
}
|
||||
|
||||
const disposition = response.headers.get("Content-Disposition") ?? "";
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import "server-only";
|
||||
|
||||
import { toJsonInput } from "@/lib/codes";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Dublettenprüfung für statische Codes.
|
||||
*
|
||||
* Ein statischer Code trägt seinen Inhalt selbst — zweimal derselbe Inhalt
|
||||
* ergibt zweimal exakt dasselbe Bild. Solche Einträge sind nicht falsch, aber
|
||||
* sie fluten die Liste, und beim Wiederfinden weiß niemand mehr, welcher der
|
||||
* beiden gemeint war.
|
||||
*
|
||||
* Bewusst **nur** für statische Codes: Zwei dynamische Codes mit demselben
|
||||
* Ziel sind ein üblicher Fall — zwei Kampagnen, zwei Kurz-Links, getrennte
|
||||
* Zähler. Da wäre eine Warnung nur lästig.
|
||||
*
|
||||
* Bewusst **ohne** die Gestaltung: Verglichen wird der Inhalt. Derselbe Inhalt
|
||||
* einmal in Schwarz-Weiß für den Druck und einmal in Hausfarben ist zwar
|
||||
* bewusst zweimal angelegt — aber eben auch zweimal derselbe Code. Die
|
||||
* Warnung sagt das und lässt sich übergehen.
|
||||
*/
|
||||
export type StaticDuplicate = {
|
||||
id: string;
|
||||
label: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sucht einen bereits abgelegten statischen Code mit gleichem Typ und
|
||||
* gleichem Inhalt in derselben Gruppe — den zuletzt angelegten.
|
||||
*
|
||||
* Die Gruppe grenzt bewusst ein: Ein Treffer aus einer fremden Gruppe wäre
|
||||
* nicht bloß nutzlos, er verriete auch deren Bestand.
|
||||
*/
|
||||
export async function findStaticDuplicate(
|
||||
groupId: string,
|
||||
type: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<StaticDuplicate | null> {
|
||||
const treffer = await prisma.qrCode.findFirst({
|
||||
where: {
|
||||
mode: "static",
|
||||
type,
|
||||
groupId,
|
||||
// Postgres vergleicht jsonb nach Struktur, nicht nach Schreibweise —
|
||||
// Schlüsselreihenfolge und Leerzeichen spielen keine Rolle.
|
||||
data: { equals: toJsonInput(data) },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { id: true, label: true, createdAt: true },
|
||||
});
|
||||
|
||||
if (!treffer) return null;
|
||||
|
||||
return {
|
||||
id: treffer.id,
|
||||
label: treffer.label,
|
||||
createdAt: treffer.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user