V1: Next.js-App mit QR-Typ-Registry, Website-Generator und dynamischen Codes

- Erweiterbare Registry aller 15 QR-Typen; nur "Website" ist aktiv, die
  uebrigen erscheinen als Platzhalter mit Badge "Bald verfuegbar".
- defineQrType() kapselt Zod-Schema, Kodierung und Redirect-Ziel je Typ,
  sodass ein V2-Typ nur aus einem Registry-Eintrag plus Formular besteht.
- Serverseitige QR-Erzeugung (PNG/SVG) mit Farben, Groesse, Fehlerkorrektur
  und Rand; Live-Vorschau ueber /api/qr/preview.
- Dynamische Codes: Kurz-URL {BASE_URL}/r/{slug}, 302-Redirect mit
  Scan-Zaehler, CRUD unter /api/codes.
- Prisma 7 mit pg-Driver-Adapter und Initial-Migration fuer PostgreSQL.
- Deutsche Oberflaeche mit Tailwind CSS, helles Theme, responsiv.
This commit is contained in:
Florian Wilde
2026-08-13 15:54:47 +02:00
commit ad4cd92f8e
52 changed files with 12348 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { shortUrl } from "@/lib/env";
import { errorResponse } from "@/lib/http";
import { designSchema } from "@/lib/qr/design";
import { getQrType } from "@/lib/qr/registry";
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
import { downloadFilename, QrRequestError } from "@/lib/qr/request";
export const dynamic = "force-dynamic";
type Context = { params: Promise<{ id: string }> };
/**
* Rendert einen gespeicherten dynamischen Code mit seiner hinterlegten
* Gestaltung. Wird für die Vorschaubilder in „Meine Codes“ und für die
* Download-Links dort verwendet.
*/
export async function GET(
request: NextRequest,
context: Context,
): Promise<Response> {
try {
const { id } = await context.params;
const format =
request.nextUrl.searchParams.get("format") === "png" ? "png" : "svg";
const isDownload = request.nextUrl.searchParams.get("download") === "1";
const code = await prisma.qrCode.findUnique({ where: { id } });
if (!code) {
throw new QrRequestError("Dieser QR-Code existiert nicht (mehr).", 404);
}
const design = designSchema.parse({
fgColor: code.fgColor,
bgColor: code.bgColor,
size: code.size,
ecLevel: code.ecLevel,
margin: code.margin,
});
const content = shortUrl(code.slug);
const body =
format === "png"
? new Uint8Array(await renderPng(content, design))
: await renderSvg(content, design);
const headers: Record<string, string> = {
"Content-Type": CONTENT_TYPES[format],
// Das Ziel eines dynamischen Codes ist änderbar; die Kurz-URL im Bild
// nicht. Trotzdem kein Caching, damit Gestaltungsänderungen sofort
// sichtbar werden.
"Cache-Control": "no-store",
};
if (isDownload) {
const module = getQrType(code.type);
const name = module
? downloadFilename(module, code.data, format)
: `qr-${code.slug}.${format}`;
headers["Content-Disposition"] = `attachment; filename="${name}"`;
}
return new Response(body, { headers });
} catch (error) {
return errorResponse(error);
}
}
+110
View File
@@ -0,0 +1,110 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import type { Prisma } from "@/generated/prisma/client";
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { designSchema } from "@/lib/qr/design";
import { getActiveQrType } from "@/lib/qr/registry";
import { QrRequestError } from "@/lib/qr/request";
export const dynamic = "force-dynamic";
type Context = { params: Promise<{ id: string }> };
const updateSchema = z.object({
data: z.record(z.string(), z.unknown()).optional(),
design: designSchema.optional(),
label: z
.string()
.trim()
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
.nullable()
.optional(),
});
async function loadCode(id: string) {
const code = await prisma.qrCode.findUnique({ where: { id } });
if (!code) {
throw new QrRequestError("Dieser QR-Code existiert nicht (mehr).", 404);
}
return code;
}
export async function GET(
_request: NextRequest,
context: Context,
): Promise<Response> {
try {
const { id } = await context.params;
return Response.json({ code: toQrCodeDto(await loadCode(id)) });
} catch (error) {
return errorResponse(error);
}
}
/** Ändert Ziel, Name oder Gestaltung — die Kurz-URL bleibt bestehen. */
export async function PATCH(
request: NextRequest,
context: Context,
): Promise<Response> {
try {
const { id } = await context.params;
const parsed = updateSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
const existing = await loadCode(id);
const update: Prisma.QrCodeUpdateInput = {};
if (parsed.data.data !== undefined) {
const module = getActiveQrType(existing.type);
if (!module) {
throw new QrRequestError(
"Der Typ dieses Codes wird in dieser Version nicht unterstützt.",
);
}
const validated = module.validate(parsed.data.data);
if (!validated.ok) {
return jsonError(validated.message);
}
update.data = toJsonInput(validated.data);
}
if (parsed.data.label !== undefined) {
update.label =
parsed.data.label && parsed.data.label.length > 0
? parsed.data.label
: null;
}
if (parsed.data.design !== undefined) {
Object.assign(update, parsed.data.design);
}
if (Object.keys(update).length === 0) {
return jsonError("Es wurden keine Änderungen übermittelt.");
}
const updated = await prisma.qrCode.update({ where: { id }, data: update });
return Response.json({ code: toQrCodeDto(updated) });
} catch (error) {
return errorResponse(error);
}
}
export async function DELETE(
_request: NextRequest,
context: Context,
): Promise<Response> {
try {
const { id } = await context.params;
await loadCode(id);
await prisma.qrCode.delete({ where: { id } });
return new Response(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
}
+97
View File
@@ -0,0 +1,97 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
import { prisma } from "@/lib/db";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { designSchema } from "@/lib/qr/design";
import { getActiveQrType } from "@/lib/qr/registry";
import { QrRequestError } from "@/lib/qr/request";
import { QR_TYPE_IDS } from "@/lib/qr/type-module";
import { generateSlug } from "@/lib/slug";
export const dynamic = "force-dynamic";
const createSchema = z.object({
type: z.enum(QR_TYPE_IDS, { error: "Unbekannter QR-Typ." }),
data: z.record(z.string(), z.unknown()),
design: designSchema,
label: z
.string()
.trim()
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
.optional(),
});
function isUniqueViolation(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
(error as { code?: unknown }).code === "P2002"
);
}
/** Alle dynamischen Codes, neueste zuerst. */
export async function GET(): Promise<Response> {
try {
const codes = await prisma.qrCode.findMany({ orderBy: { createdAt: "desc" } });
return Response.json({ codes: codes.map(toQrCodeDto) });
} catch (error) {
return errorResponse(error);
}
}
/** Legt einen dynamischen Code an und vergibt dafür eine freie Kurz-URL. */
export async function POST(request: NextRequest): Promise<Response> {
try {
const parsed = createSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
const { type, design, label } = parsed.data;
const module = getActiveQrType(type);
if (!module) {
throw new QrRequestError(
"Dieser QR-Typ ist in dieser Version noch nicht verfügbar.",
404,
);
}
if (!module.supportsDynamic) {
throw new QrRequestError(
`Der QR-Typ „${module.title}" unterstützt keine dynamischen Codes.`,
);
}
const validated = module.validate(parsed.data.data);
if (!validated.ok) {
return jsonError(validated.message);
}
// Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich.
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
const created = await prisma.qrCode.create({
data: {
slug: generateSlug(),
type: module.id,
label: label && label.length > 0 ? label : null,
data: toJsonInput(validated.data),
...design,
},
});
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
} catch (error) {
if (!isUniqueViolation(error)) throw error;
}
}
throw new QrRequestError(
"Es konnte keine freie Kurz-URL erzeugt werden. Bitte versuche es erneut.",
500,
);
} catch (error) {
return errorResponse(error);
}
}
+51
View File
@@ -0,0 +1,51 @@
import type { NextRequest } from "next/server";
import { z } from "zod";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
import {
downloadFilename,
qrRequestSchema,
QrRequestError,
resolveQrRequest,
} from "@/lib/qr/request";
const downloadSchema = qrRequestSchema.extend({
format: z.enum(["png", "svg"], { error: "Ungültiges Dateiformat." }),
});
/** Liefert den fertigen QR-Code als PNG- oder SVG-Datei zum Herunterladen. */
export async function POST(request: NextRequest): Promise<Response> {
try {
const parsed = downloadSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
const { format, design } = parsed.data;
// Ein dynamischer Code ohne Slug wäre eine Weiterleitung ins Leere.
if (parsed.data.mode === "dynamic" && !parsed.data.slug) {
throw new QrRequestError(
"Bitte speichere den dynamischen Code zuerst — erst dann existiert die Kurz-URL.",
);
}
const { module, data, content } = resolveQrRequest(parsed.data);
const body =
format === "png"
? new Uint8Array(await renderPng(content, design))
: await renderSvg(content, design);
return new Response(body, {
headers: {
"Content-Type": CONTENT_TYPES[format],
"Content-Disposition": `attachment; filename="${downloadFilename(module, data, format)}"`,
"Cache-Control": "no-store",
},
});
} catch (error) {
return errorResponse(error);
}
}
+28
View File
@@ -0,0 +1,28 @@
import type { NextRequest } from "next/server";
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
import { renderSvg } from "@/lib/qr/render";
import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request";
/**
* Liefert die Live-Vorschau als SVG-Data-URL. Die Erzeugung passiert
* serverseitig, damit Vorschau und Download garantiert identisch sind.
*/
export async function POST(request: NextRequest): Promise<Response> {
try {
const parsed = qrRequestSchema.safeParse(await request.json());
if (!parsed.success) {
return jsonError(firstZodMessage(parsed.error));
}
const { content } = resolveQrRequest(parsed.data);
const svg = await renderSvg(content, parsed.data.design);
return Response.json({
content,
dataUrl: `data:image/svg+xml;base64,${Buffer.from(svg, "utf8").toString("base64")}`,
});
} catch (error) {
return errorResponse(error);
}
}
+56
View File
@@ -0,0 +1,56 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Icon } from "@/components/icons";
import { QrGenerator } from "@/components/qr-generator";
import { QR_TYPE_IDS, getActiveQrType, getQrType } from "@/lib/qr/registry";
type Props = { params: Promise<{ type: string }> };
/** Alle bekannten Typen vorab erzeugen — auch die noch inaktiven. */
export function generateStaticParams() {
return QR_TYPE_IDS.map((type) => ({ type }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { type } = await params;
const module = getQrType(type);
return { title: module ? `${module.title}-QR-Code` : "QR-Code erstellen" };
}
export default async function CreatePage({ params }: Props) {
const { type } = await params;
const module = getQrType(type);
if (!module) {
notFound();
}
// Registrierte, aber noch nicht implementierte Typen bekommen eine
// erklärende Seite statt einer 404.
if (!getActiveQrType(type)) {
return (
<div className="mx-auto max-w-2xl px-4 py-16 text-center sm:px-6">
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
<Icon name={module.icon} className="size-7" />
</span>
<h1 className="mt-5 text-2xl font-bold text-slate-900">
„{module.title}“ ist bald verfügbar
</h1>
<p className="mt-2 text-slate-600">
Dieser QR-Typ folgt in Version 2. In Version 1 steht der Typ „Website“
zur Verfügung.
</p>
<Link
href="/erstellen/website"
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
>
Website-QR-Code erstellen
</Link>
</div>
);
}
return <QrGenerator typeId={module.id} />;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+43
View File
@@ -0,0 +1,43 @@
@import "tailwindcss";
@theme inline {
--font-sans:
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
Arial, "Noto Sans", sans-serif;
--font-mono:
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono",
monospace;
}
:root {
color-scheme: light;
}
body {
background-color: var(--color-slate-50);
color: var(--color-slate-900);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
/* Sichtbarer, einheitlicher Fokusring für Tastaturbedienung. */
:focus-visible {
outline: 2px solid var(--color-indigo-600);
outline-offset: 2px;
}
/* Schachbrett hinter der Vorschau — macht helle Hintergrundfarben sichtbar. */
.qr-canvas {
background-color: #fff;
background-image:
linear-gradient(45deg, #eef2f7 25%, transparent 25%),
linear-gradient(-45deg, #eef2f7 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #eef2f7 75%),
linear-gradient(-45deg, transparent 75%, #eef2f7 75%);
background-size: 16px 16px;
background-position:
0 0,
0 8px,
8px -8px,
-8px 0;
}
+58
View File
@@ -0,0 +1,58 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Icon } from "@/components/icons";
import "./globals.css";
export const metadata: Metadata = {
title: {
default: "QR-Code-Generator",
template: "%s · QR-Code-Generator",
},
description:
"QR-Codes erstellen: statisch oder dynamisch, mit eigenen Farben, frei wählbarer Größe und Download als PNG oder SVG.",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="de" className="h-full">
<body className="flex min-h-full flex-col">
<header className="border-b border-slate-200 bg-white">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3.5 sm:px-6">
<Link
href="/"
className="flex items-center gap-2.5 rounded-lg font-semibold text-slate-900"
>
<span className="flex size-9 items-center justify-center rounded-lg bg-indigo-600 text-white">
<Icon name="qr" className="size-5" />
</span>
QR-Code-Generator
</Link>
<nav className="flex items-center gap-1 text-sm font-medium">
<Link
href="/"
className="rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
>
Erstellen
</Link>
<Link
href="/meine-codes"
className="rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
>
Meine Codes
</Link>
</nav>
</div>
</header>
<main className="flex-1">{children}</main>
<footer className="border-t border-slate-200 bg-white">
<div className="mx-auto max-w-6xl px-4 py-6 text-sm text-slate-500 sm:px-6">
QR-Code-Generator · Version 1 — QR-Typ „Website/URL"
</div>
</footer>
</body>
</html>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { CodesList } from "@/components/codes-list";
export const metadata: Metadata = { title: "Meine Codes" };
export default function MyCodesPage() {
return (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-10">
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
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.
</p>
</div>
<CodesList />
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import Link from "next/link";
export default function NotFound() {
return (
<div className="mx-auto max-w-lg px-4 py-20 text-center sm:px-6">
<p className="text-5xl font-bold text-slate-300">404</p>
<h1 className="mt-4 text-2xl font-bold text-slate-900">
Seite nicht gefunden
</h1>
<p className="mt-2 text-slate-600">
Diese Seite gibt es nicht. Vielleicht hilft der Weg zurück zur
Typ-Auswahl.
</p>
<Link
href="/"
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
>
Zur Startseite
</Link>
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
import Link from "next/link";
import { Icon } from "@/components/icons";
import { Badge } from "@/components/ui";
import { qrTypeList } from "@/lib/qr/registry";
export default function HomePage() {
return (
<div className="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
<div className="max-w-2xl">
<h1 className="text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl">
Was möchtest du erstellen?
</h1>
<p className="mt-3 text-base text-slate-600">
Wähle einen Typ, gib deine Daten ein und lade den fertigen QR-Code als
PNG oder SVG herunter. Auf Wunsch als dynamischer Code, dessen Ziel du
später änderst, ohne den Code neu drucken zu müssen.
</p>
</div>
<ul className="mt-9 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{qrTypeList.map((type) => {
const tile = (
<>
<span
className={
type.active
? "flex size-11 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600"
: "flex size-11 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-400"
}
>
<Icon name={type.icon} className="size-6" />
</span>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span
className={
type.active
? "font-semibold text-slate-900"
: "font-semibold text-slate-500"
}
>
{type.title}
</span>
{type.active ? (
<Badge tone="popular">Beliebt</Badge>
) : (
<Badge tone="soon">Bald verfügbar</Badge>
)}
</span>
<span
className={
type.active
? "mt-1 block text-sm text-slate-600"
: "mt-1 block text-sm text-slate-400"
}
>
{type.description}
</span>
</span>
</>
);
return (
<li key={type.id}>
{type.active ? (
<Link
href={`/erstellen/${type.id}`}
className="flex h-full items-start gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
>
{tile}
</Link>
) : (
<div
aria-disabled="true"
title={`„${type.title}" folgt in Version 2`}
className="flex h-full cursor-not-allowed items-start gap-4 rounded-2xl border border-dashed border-slate-200 bg-slate-50/60 p-4"
>
{tile}
</div>
)}
</li>
);
})}
</ul>
<p className="mt-8 text-sm text-slate-500">
In Version 1 ist der Typ „Website" verfügbar. Die übrigen Typen folgen in
Version 2.
</p>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { getQrType } from "@/lib/qr/registry";
import { isValidSlug } from "@/lib/slug";
export const dynamic = "force-dynamic";
type Context = { params: Promise<{ slug: string }> };
function notFound(message: string): Response {
return new Response(
`<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>QR-Code nicht gefunden</title>
</head>
<body style="font-family: system-ui, sans-serif; margin: 0; display: grid; place-items: center; min-height: 100vh; background: #f8fafc; color: #0f172a;">
<main style="text-align: center; padding: 2rem; max-width: 32rem;">
<p style="font-size: 3rem; margin: 0;">🔍</p>
<h1 style="font-size: 1.5rem; margin: 1rem 0 0.5rem;">QR-Code nicht gefunden</h1>
<p style="color: #475569; line-height: 1.6;">${message}</p>
</main>
</body>
</html>`,
{ status: 404, headers: { "Content-Type": "text/html; charset=utf-8" } },
);
}
/**
* Weiterleitung eines dynamischen QR-Codes.
*
* Bewusst 302 (temporär): Das Ziel ist jederzeit änderbar, ein permanenter
* Redirect würde in Browsern und Zwischenspeichern hängen bleiben.
*/
export async function GET(
_request: NextRequest,
context: Context,
): Promise<Response> {
const { slug } = await context.params;
if (!isValidSlug(slug)) {
return notFound("Diese Kurz-Adresse ist ungültig.");
}
const code = await prisma.qrCode.findUnique({ where: { slug } });
if (!code) {
return notFound(
"Der Code wurde gelöscht oder hat nie existiert. Bitte wende dich an denjenigen, der ihn erstellt hat.",
);
}
const module = getQrType(code.type);
if (!module) {
return notFound("Der Typ dieses Codes wird nicht mehr unterstützt.");
}
let target: string;
try {
target = module.redirectTarget(code.data);
} catch {
return notFound("Das Ziel dieses Codes ist nicht mehr gültig.");
}
// Der Zähler darf die Weiterleitung nicht ausbremsen oder blockieren.
try {
await prisma.qrCode.update({
where: { id: code.id },
data: { scans: { increment: 1 }, lastScanAt: new Date() },
});
} catch (error) {
console.error("Scan-Zähler konnte nicht erhöht werden:", error);
}
return new Response(null, {
status: 302,
headers: {
Location: target,
// Ohne dies würden Browser die Weiterleitung zwischenspeichern und
// spätere Zieländerungen sowie der Scan-Zähler blieben wirkungslos.
"Cache-Control": "no-store, no-cache, must-revalidate",
},
});
}
+396
View File
@@ -0,0 +1,396 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Icon } from "@/components/icons";
import { Modal } from "@/components/modal";
import { qrForms } from "@/components/qr-forms";
import { Badge, Button, Card, Field, Notice, TextInput } from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client";
import type { QrCodeDto } from "@/lib/dto";
import { qrTypes } from "@/lib/qr/registry";
import type { QrFormData } from "@/lib/qr/type-module";
const dateFormat = new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
});
function formatDate(value: string | null): string {
if (!value) return "—";
return dateFormat.format(new Date(value));
}
export function CodesList() {
const [codes, setCodes] = useState<QrCodeDto[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [editing, setEditing] = useState<QrCodeDto | null>(null);
const [editData, setEditData] = useState<QrFormData>({});
const [editLabel, setEditLabel] = useState("");
const [editError, setEditError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState<QrCodeDto | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [removing, setRemoving] = useState(false);
const [copiedId, setCopiedId] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const result = await apiJson<{ codes: QrCodeDto[] }>("/api/codes");
setCodes(result.codes);
setLoadError(null);
} catch (error) {
setCodes([]);
setLoadError(
error instanceof ApiError
? error.message
: "Die Liste konnte nicht geladen werden.",
);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const openEdit = (code: QrCodeDto) => {
setEditing(code);
setEditData(code.data);
setEditLabel(code.label ?? "");
setEditError(null);
};
const EditForm = editing ? qrForms[editing.type] : undefined;
const editValidation = useMemo(
() => (editing ? qrTypes[editing.type].validate(editData) : null),
[editing, editData],
);
const handleSave = async () => {
if (!editing || !editValidation) return;
if (!editValidation.ok) {
setEditError(editValidation.message);
return;
}
setSaving(true);
setEditError(null);
try {
await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, {
method: "PATCH",
json: { data: editValidation.data, label: editLabel.trim() || null },
});
setEditing(null);
await load();
} catch (error) {
setEditError(
error instanceof ApiError
? error.message
: "Die Änderung konnte nicht gespeichert werden.",
);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deleting) return;
setRemoving(true);
setDeleteError(null);
try {
await apiJson(`/api/codes/${deleting.id}`, { method: "DELETE" });
setDeleting(null);
await load();
} catch (error) {
setDeleteError(
error instanceof ApiError
? error.message
: "Der Code konnte nicht gelöscht werden.",
);
} finally {
setRemoving(false);
}
};
const copy = async (code: QrCodeDto) => {
try {
await navigator.clipboard.writeText(code.shortUrl);
setCopiedId(code.id);
setTimeout(() => setCopiedId(null), 2000);
} catch {
setCopiedId(null);
}
};
/* ---------------------------------------------------------------- View */
if (codes === null) {
return <p className="text-sm text-slate-500">Codes werden geladen …</p>;
}
return (
<div className="space-y-4">
{loadError ? <Notice tone="error">{loadError}</Notice> : null}
{codes.length === 0 && !loadError ? (
<Card className="text-center">
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
<Icon name="qr" className="size-7" />
</span>
<h2 className="mt-4 text-lg font-semibold text-slate-900">
Noch keine dynamischen Codes
</h2>
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600">
Erstelle einen QR-Code und wähle dabei „Dynamisch“ — dann erscheint
er hier und du kannst sein Ziel jederzeit ändern.
</p>
<Link
href="/erstellen/website"
className="mt-5 inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
>
QR-Code erstellen
</Link>
</Card>
) : null}
{codes.map((code) => (
<Card key={code.id}>
<div className="flex flex-col gap-5 sm:flex-row">
<a
href={`/api/codes/${code.id}/qr?format=png&download=1`}
title="Als PNG herunterladen"
className="qr-canvas size-28 shrink-0 self-start rounded-xl border border-slate-200 p-1.5"
>
{/* eslint-disable-next-line @next/next/no-img-element -- vom Server gerendertes SVG */}
<img
src={`/api/codes/${code.id}/qr`}
alt={`QR-Code für ${code.summary}`}
className="h-full w-full object-contain"
/>
</a>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="font-semibold text-slate-900">
{code.label ?? code.summary}
</h2>
<Badge tone="neutral">{qrTypes[code.type]?.title ?? code.type}</Badge>
</div>
<dl className="mt-3 space-y-2 text-sm">
<div className="flex flex-wrap items-baseline gap-x-2">
<dt className="text-slate-500">Kurz-URL:</dt>
<dd className="flex items-center gap-2">
<a
href={code.shortUrl}
target="_blank"
rel="noreferrer"
className="font-mono text-xs break-all text-indigo-600 hover:underline"
>
{code.shortUrl}
</a>
<button
type="button"
onClick={() => void copy(code)}
title="Kurz-URL kopieren"
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
>
<Icon
name={copiedId === code.id ? "check" : "copy"}
className="size-4"
/>
</button>
</dd>
</div>
<div className="flex flex-wrap items-baseline gap-x-2">
<dt className="text-slate-500">Ziel:</dt>
<dd className="min-w-0 break-all text-slate-800">
{code.target ? (
<a
href={code.target}
target="_blank"
rel="noreferrer"
className="hover:underline"
>
{code.target}
</a>
) : (
<span className="text-red-600">{code.summary}</span>
)}
</dd>
</div>
</dl>
<div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1 text-sm text-slate-500">
<span className="flex items-center gap-1.5">
<Icon name="scan" className="size-4" />
<strong className="font-semibold text-slate-900 tabular-nums">
{code.scans}
</strong>
{code.scans === 1 ? "Scan" : "Scans"}
</span>
<span>Erstellt: {formatDate(code.createdAt)}</span>
<span>Letzter Scan: {formatDate(code.lastScanAt)}</span>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => openEdit(code)}
>
<Icon name="pencil" className="size-3.5" />
Ziel bearbeiten
</Button>
<a
href={`/api/codes/${code.id}/qr?format=png&download=1`}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
<Icon name="download" className="size-3.5" />
PNG
</a>
<a
href={`/api/codes/${code.id}/qr?format=svg&download=1`}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
<Icon name="download" className="size-3.5" />
SVG
</a>
<Button
type="button"
variant="ghost"
size="sm"
className="text-red-600 hover:bg-red-50"
onClick={() => {
setDeleting(code);
setDeleteError(null);
}}
>
<Icon name="trash" className="size-3.5" />
Löschen
</Button>
</div>
</div>
</div>
</Card>
))}
{/* ------------------------------------------------------ Bearbeiten */}
<Modal
open={editing !== null}
title="Ziel bearbeiten"
onClose={() => setEditing(null)}
footer={
<>
<Button
type="button"
variant="secondary"
onClick={() => setEditing(null)}
disabled={saving}
>
Abbrechen
</Button>
<Button type="button" onClick={() => void handleSave()} disabled={saving}>
{saving ? "Speichert …" : "Speichern"}
</Button>
</>
}
>
{editing ? (
<div className="space-y-4">
<p className="text-sm text-slate-600">
Der gedruckte QR-Code bleibt unverändert gültig — er zeigt weiterhin
auf{" "}
<code className="font-mono text-xs break-all">
{editing.shortUrl}
</code>
.
</p>
{EditForm ? (
<EditForm
value={editData}
onChange={(value) => {
setEditData(value);
setEditError(null);
}}
error={editError}
disabled={saving}
/>
) : (
<Notice tone="error">
Für diesen Typ ist kein Formular verfügbar.
</Notice>
)}
<Field label="Name (optional)" htmlFor="edit-label">
<TextInput
id="edit-label"
value={editLabel}
maxLength={120}
placeholder="z. B. Plakat Eingang"
disabled={saving}
onChange={(event) => setEditLabel(event.target.value)}
/>
</Field>
</div>
) : null}
</Modal>
{/* --------------------------------------------------------- Löschen */}
<Modal
open={deleting !== null}
title="QR-Code löschen?"
onClose={() => setDeleting(null)}
footer={
<>
<Button
type="button"
variant="secondary"
onClick={() => setDeleting(null)}
disabled={removing}
>
Abbrechen
</Button>
<Button
type="button"
variant="danger"
onClick={() => void handleDelete()}
disabled={removing}
>
{removing ? "Löscht …" : "Endgültig löschen"}
</Button>
</>
}
>
{deleting ? (
<div className="space-y-3">
<p className="text-sm text-slate-600">
Der Code{" "}
<strong className="text-slate-900">
{deleting.label ?? deleting.summary}
</strong>{" "}
wird gelöscht. Bereits gedruckte oder verteilte Codes mit der
Kurz-URL{" "}
<code className="font-mono text-xs break-all">
{deleting.shortUrl}
</code>{" "}
führen danach ins Leere. Das lässt sich nicht rückgängig machen.
</p>
{deleting.scans > 0 ? (
<Notice tone="warning">
Dieser Code wurde bereits {deleting.scans}-mal gescannt.
</Notice>
) : null}
{deleteError ? <Notice tone="error">{deleteError}</Notice> : null}
</div>
) : null}
</Modal>
</div>
);
}
+213
View File
@@ -0,0 +1,213 @@
"use client";
import { Icon } from "@/components/icons";
import { Field, Notice, TextInput, cx } from "@/components/ui";
import { evaluateContrast } from "@/lib/qr/contrast";
import {
COLOR_PRESETS,
EC_LEVELS,
EC_LEVEL_LABELS,
MAX_SIZE,
MIN_SIZE,
type EcLevel,
type QrDesign,
} from "@/lib/qr/design";
const HEX_INPUT = /^#[0-9a-fA-F]{6}$/;
function ColorField({
label,
value,
onChange,
id,
}: {
label: string;
value: string;
onChange: (value: string) => void;
id: string;
}) {
const valid = HEX_INPUT.test(value);
return (
<Field
label={label}
htmlFor={id}
error={valid ? null : "Bitte im Format #RRGGBB angeben."}
>
<div className="flex items-center gap-2">
<input
type="color"
aria-label={`${label} auswählen`}
value={valid ? value : "#000000"}
onChange={(event) => onChange(event.target.value.toUpperCase())}
className="size-10 shrink-0 cursor-pointer rounded-lg border border-slate-300 bg-white p-1"
/>
<TextInput
id={id}
value={value}
invalid={!valid}
spellCheck={false}
maxLength={7}
onChange={(event) => {
const next = event.target.value.trim();
onChange(next.startsWith("#") ? next.toUpperCase() : `#${next.toUpperCase()}`);
}}
className="font-mono uppercase"
/>
</div>
</Field>
);
}
export function DesignPanel({
design,
onChange,
}: {
design: QrDesign;
onChange: (design: QrDesign) => void;
}) {
const patch = (changes: Partial<QrDesign>) => onChange({ ...design, ...changes });
const contrast = evaluateContrast(design.fgColor, design.bgColor);
return (
<div className="space-y-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<ColorField
id="fg-color"
label="Vordergrund"
value={design.fgColor}
onChange={(fgColor) => patch({ fgColor })}
/>
<ColorField
id="bg-color"
label="Hintergrund"
value={design.bgColor}
onChange={(bgColor) => patch({ bgColor })}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
{COLOR_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
title={preset.label}
onClick={() =>
patch({ fgColor: preset.fgColor, bgColor: preset.bgColor })
}
className="flex items-center gap-2 rounded-lg border border-slate-300 bg-white py-1 pr-2.5 pl-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
<span
className="size-4 rounded border border-slate-300"
style={{
background: `linear-gradient(135deg, ${preset.fgColor} 50%, ${preset.bgColor} 50%)`,
}}
/>
{preset.label}
</button>
))}
<button
type="button"
onClick={() =>
patch({ fgColor: design.bgColor, bgColor: design.fgColor })
}
className="rounded-lg border border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
Farben tauschen
</button>
</div>
{contrast.message ? (
<Notice tone={contrast.verdict === "schlecht" ? "error" : "warning"}>
<span className="flex gap-2">
<Icon name="warning" className="mt-0.5 size-4 shrink-0" />
<span>
{contrast.message}
<span className="mt-0.5 block text-xs opacity-80">
Kontrastverhältnis: {contrast.ratio.toFixed(1)} : 1
</span>
</span>
</span>
</Notice>
) : null}
<Field
label={`Größe: ${design.size} × ${design.size} px`}
htmlFor="qr-size"
hint={`Zwischen ${MIN_SIZE} und ${MAX_SIZE} px. Gilt für den PNG-Download; das SVG skaliert verlustfrei.`}
>
<div className="flex items-center gap-3">
<input
id="qr-size"
type="range"
min={MIN_SIZE}
max={MAX_SIZE}
step={32}
value={design.size}
onChange={(event) => patch({ size: Number(event.target.value) })}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600"
/>
<input
type="number"
aria-label="Größe in Pixeln"
min={MIN_SIZE}
max={MAX_SIZE}
value={design.size}
onChange={(event) => {
const raw = Number(event.target.value);
if (Number.isNaN(raw)) return;
patch({ size: Math.min(MAX_SIZE, Math.max(MIN_SIZE, Math.round(raw))) });
}}
className="w-24 shrink-0 rounded-lg border border-slate-300 bg-white px-2.5 py-2 text-sm tabular-nums shadow-sm"
/>
</div>
</Field>
<Field
label="Fehlerkorrektur"
hint={EC_LEVEL_LABELS[design.ecLevel]}
>
<div
role="radiogroup"
aria-label="Fehlerkorrektur-Level"
className="grid grid-cols-4 gap-2"
>
{EC_LEVELS.map((level: EcLevel) => (
<button
key={level}
type="button"
role="radio"
aria-checked={design.ecLevel === level}
onClick={() => patch({ ecLevel: level })}
className={cx(
"rounded-lg border py-2 text-sm font-medium transition-colors",
design.ecLevel === level
? "border-indigo-600 bg-indigo-50 text-indigo-700"
: "border-slate-300 bg-white text-slate-600 hover:bg-slate-50",
)}
>
{level}
</button>
))}
</div>
</Field>
<Field
label={`Rand: ${design.margin} Module`}
htmlFor="qr-margin"
hint="Die weiße Ruhezone rundherum. Weniger als 4 Module können das Scannen erschweren."
>
<input
id="qr-margin"
type="range"
min={0}
max={16}
step={1}
value={design.margin}
onChange={(event) => patch({ margin: Number(event.target.value) })}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600"
/>
</Field>
</div>
);
}
+203
View File
@@ -0,0 +1,203 @@
import type { SVGProps } from "react";
/**
* Kleines Icon-Set für die QR-Typ-Kacheln und die Oberfläche.
* Einheitlicher Stil: 24×24, Strichstärke 1.75, runde Enden.
*/
const PATHS = {
globe: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18" />
<path d="M12 3a15 15 0 0 1 0 18a15 15 0 0 1 0-18" />
</>
),
contact: (
<>
<rect x="3" y="4" width="18" height="16" rx="2" />
<circle cx="10" cy="10.5" r="2.25" />
<path d="M6.5 16.5a4 4 0 0 1 7 0" />
<path d="M16 9.5h3M16 13h3" />
</>
),
smartphone: (
<>
<rect x="6" y="2.5" width="12" height="19" rx="2.5" />
<path d="M10.5 18.5h3" />
</>
),
list: (
<>
<path d="M8 6h12M8 12h12M8 18h12" />
<circle cx="4" cy="6" r="1.1" />
<circle cx="4" cy="12" r="1.1" />
<circle cx="4" cy="18" r="1.1" />
</>
),
share: (
<>
<circle cx="17.5" cy="5.5" r="2.5" />
<circle cx="6.5" cy="12" r="2.5" />
<circle cx="17.5" cy="18.5" r="2.5" />
<path d="M8.8 10.8 15.2 6.7M8.8 13.2l6.4 4.1" />
</>
),
chat: (
<>
<path d="M21 11.5a8 8 0 0 1-11.9 7L3.5 20l1.6-5.3A8 8 0 1 1 21 11.5Z" />
<path d="M8.5 10.5h7M8.5 14h4" />
</>
),
document: (
<>
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z" />
<path d="M14 3v5h5" />
<path d="M9 13h6M9 16.5h4" />
</>
),
image: (
<>
<rect x="3" y="4.5" width="18" height="15" rx="2" />
<circle cx="8.5" cy="10" r="1.6" />
<path d="m4 17 4.8-4.4a1.5 1.5 0 0 1 2 0L20 19" />
</>
),
video: (
<>
<rect x="3" y="5.5" width="12.5" height="13" rx="2" />
<path d="m15.5 10.5 5.5-3v9l-5.5-3Z" />
</>
),
mail: (
<>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m3.5 7 8.5 6 8.5-6" />
</>
),
message: (
<>
<path d="M20 15a2 2 0 0 1-2 2H8l-4 3.5V6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2Z" />
<path d="M8 8.5h8M8 12h5" />
</>
),
pin: (
<>
<path d="M12 21s7-6.2 7-11a7 7 0 1 0-14 0c0 4.8 7 11 7 11Z" />
<circle cx="12" cy="10" r="2.5" />
</>
),
card: (
<>
<rect x="2.5" y="5" width="19" height="14" rx="2.5" />
<path d="M2.5 10h19" />
<path d="M6.5 14.5h3" />
</>
),
wifi: (
<>
<path d="M2.5 9a15 15 0 0 1 19 0" />
<path d="M5.8 12.6a10 10 0 0 1 12.4 0" />
<path d="M9.1 16.2a5 5 0 0 1 5.8 0" />
<circle cx="12" cy="19.5" r="0.9" fill="currentColor" stroke="none" />
</>
),
star: (
<>
<path d="m12 3.5 2.6 5.4 5.9.8-4.3 4.2 1 5.9-5.2-2.8-5.2 2.8 1-5.9L3.5 9.7l5.9-.8Z" />
</>
),
qr: (
<>
<rect x="3" y="3" width="7" height="7" rx="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" />
<path d="M14 14h3v3h-3zM18 18h3v3h-3zM14 20.5h1.5" />
</>
),
check: <path d="m4.5 12.5 5 5 10-11" />,
copy: (
<>
<rect x="9" y="9" width="12" height="12" rx="2" />
<path d="M15 5.5A2.5 2.5 0 0 0 12.5 3h-7A2.5 2.5 0 0 0 3 5.5v7A2.5 2.5 0 0 0 5.5 15" />
</>
),
download: (
<>
<path d="M12 3.5v11" />
<path d="m7.5 10.5 4.5 4.5 4.5-4.5" />
<path d="M4 17.5v1.5a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-1.5" />
</>
),
trash: (
<>
<path d="M4 6.5h16" />
<path d="M9 6.5V4.8A1.8 1.8 0 0 1 10.8 3h2.4A1.8 1.8 0 0 1 15 4.8v1.7" />
<path d="M6.5 6.5 7.4 20a1.5 1.5 0 0 0 1.5 1.4h6.2a1.5 1.5 0 0 0 1.5-1.4l.9-13.5" />
<path d="M10.5 10.5v7M13.5 10.5v7" />
</>
),
pencil: (
<>
<path d="M4 20.5h4l11-11a2.5 2.5 0 0 0-3.5-3.5l-11 11Z" />
<path d="m14.5 7 2.5 2.5" />
</>
),
arrowLeft: (
<>
<path d="M20 12H4" />
<path d="m10 6-6 6 6 6" />
</>
),
external: (
<>
<path d="M14 4h6v6" />
<path d="M20 4 11 13" />
<path d="M18 14.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4.5" />
</>
),
warning: (
<>
<path d="M10.3 4.2 2.6 17.5A2 2 0 0 0 4.3 20.5h15.4a2 2 0 0 0 1.7-3L13.7 4.2a2 2 0 0 0-3.4 0Z" />
<path d="M12 9.5v4.2" />
<circle cx="12" cy="17" r="0.9" fill="currentColor" stroke="none" />
</>
),
scan: (
<>
<path d="M4 8V6a2 2 0 0 1 2-2h2" />
<path d="M16 4h2a2 2 0 0 1 2 2v2" />
<path d="M20 16v2a2 2 0 0 1-2 2h-2" />
<path d="M8 20H6a2 2 0 0 1-2-2v-2" />
<path d="M4 12h16" />
</>
),
lock: (
<>
<rect x="4.5" y="10.5" width="15" height="10.5" rx="2" />
<path d="M8 10.5V7.5a4 4 0 0 1 8 0v3" />
</>
),
} as const;
export type IconName = keyof typeof PATHS;
type IconProps = SVGProps<SVGSVGElement> & {
name: IconName;
};
export function Icon({ name, ...props }: IconProps) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
{PATHS[name]}
</svg>
);
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
/** Schlichter modaler Dialog: Fokusfalle über <dialog>, Escape schließt. */
export function Modal({
open,
title,
onClose,
children,
footer,
}: {
open: boolean;
title: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
}) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
if (open && !dialog.open) {
dialog.showModal();
} else if (!open && dialog.open) {
dialog.close();
}
}, [open]);
return (
<dialog
ref={ref}
onCancel={(event) => {
event.preventDefault();
onClose();
}}
onClick={(event) => {
// 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-white p-0 shadow-xl backdrop:bg-slate-900/40"
>
<div className="p-5 sm:p-6">
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
<div className="mt-4">{children}</div>
{footer ? (
<div className="mt-6 flex flex-wrap justify-end gap-3">{footer}</div>
) : null}
</div>
</dialog>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { ComponentType } from "react";
import { WebsiteForm } from "@/components/qr-forms/website-form";
import type { QrFormProps } from "@/components/qr-forms/types";
import type { QrTypeId } from "@/lib/qr/type-module";
/**
* Zuordnung QR-Typ → Formular-Komponente.
*
* Gegenstück zur Typ-Registry in `src/lib/qr/registry.ts`: dort liegt die
* Logik (Validierung, Kodierung), hier die Eingabemaske. Ein V2-Typ braucht
* genau einen zusätzlichen Eintrag an beiden Stellen.
*/
export const qrForms: Partial<Record<QrTypeId, ComponentType<QrFormProps>>> = {
website: WebsiteForm,
};
export type { QrFormProps };
+14
View File
@@ -0,0 +1,14 @@
import type { QrFormData } from "@/lib/qr/type-module";
/**
* Einheitliche Schnittstelle aller Typ-Formulare. Das Formular kennt nur
* seine eigenen Felder und meldet Änderungen als flaches Objekt zurück —
* dasselbe Format, das das zugehörige Typ-Modul validiert.
*/
export type QrFormProps = {
value: QrFormData;
onChange: (value: QrFormData) => void;
/** Validierungsmeldung zum aktuellen Inhalt. */
error?: string | null;
disabled?: boolean;
};
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { Field, TextInput } from "@/components/ui";
import type { QrFormProps } from "@/components/qr-forms/types";
export function WebsiteForm({ value, onChange, error, disabled }: QrFormProps) {
const url = typeof value.url === "string" ? value.url : "";
// Häufigster Eingabefehler: die Adresse wird ohne Schema getippt.
const missingScheme = url.trim().length > 0 && !/^[a-z][a-z0-9+.-]*:/i.test(url);
return (
<div>
<Field
label="Website-Adresse"
htmlFor="website-url"
error={error}
hint="Die vollständige Adresse inklusive https://"
>
<TextInput
id="website-url"
type="text"
inputMode="url"
autoComplete="url"
spellCheck={false}
placeholder="https://example.com"
value={url}
invalid={Boolean(error)}
disabled={disabled}
onChange={(event) => onChange({ ...value, url: event.target.value })}
/>
</Field>
{missingScheme ? (
<button
type="button"
onClick={() =>
onChange({ ...value, url: `https://${url.trim().replace(/^\/+/, "")}` })
}
className="mt-2 rounded-lg border border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
https:// voranstellen
</button>
) : null}
</div>
);
}
+418
View File
@@ -0,0 +1,418 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Icon } from "@/components/icons";
import { DesignPanel } from "@/components/design-panel";
import { QrPreview } from "@/components/qr-preview";
import { qrForms } from "@/components/qr-forms";
import {
Badge,
Button,
Card,
CardTitle,
Field,
Notice,
TextInput,
cx,
} from "@/components/ui";
import { ApiError, apiJson, downloadFile } from "@/lib/api-client";
import type { QrCodeDto } from "@/lib/dto";
import { defaultDesign, designSchema, type QrDesign } from "@/lib/qr/design";
import { qrTypes } from "@/lib/qr/registry";
import type { QrFormData, QrTypeId } from "@/lib/qr/type-module";
type PreviewState = { dataUrl: string; content: string };
/** Vergleicht zwei Datenobjekte unabhängig von der Schlüsselreihenfolge. */
function stableStringify(value: unknown): string {
return JSON.stringify(value, (_key, inner: unknown) =>
inner !== null && typeof inner === "object" && !Array.isArray(inner)
? Object.fromEntries(
Object.entries(inner as Record<string, unknown>).sort(([a], [b]) =>
a.localeCompare(b),
),
)
: inner,
);
}
function sameData(a: unknown, b: unknown): boolean {
return stableStringify(a) === stableStringify(b);
}
export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
const module = qrTypes[typeId];
const Form = qrForms[typeId];
const [data, setData] = useState<QrFormData>(module.defaultData);
const [design, setDesign] = useState<QrDesign>(defaultDesign);
const [isDynamic, setIsDynamic] = useState(false);
const [label, setLabel] = useState("");
const [saved, setSaved] = useState<QrCodeDto | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [preview, setPreview] = useState<PreviewState | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [downloading, setDownloading] = useState<"png" | "svg" | null>(null);
const [downloadError, setDownloadError] = useState<string | null>(null);
const validation = useMemo(() => module.validate(data), [module, data]);
const designValid = useMemo(
() => designSchema.safeParse(design).success,
[design],
);
// Fehler erst zeigen, wenn tatsächlich etwas eingegeben wurde.
const touched = !sameData(data, module.defaultData);
const dataError = !validation.ok && touched ? validation.message : null;
const mode = isDynamic ? "dynamic" : "static";
const slug = isDynamic ? saved?.slug : undefined;
const requestBody = useMemo(
() => ({ type: typeId, data, design, mode, slug }),
[typeId, data, design, mode, slug],
);
const ready = validation.ok && designValid;
/* ------------------------------------------------------------ Vorschau */
const requestId = useRef(0);
useEffect(() => {
if (!ready) {
setPreview(null);
setPreviewError(null);
setPreviewLoading(false);
return;
}
const id = (requestId.current += 1);
const controller = new AbortController();
setPreviewLoading(true);
const timer = setTimeout(() => {
apiJson<PreviewState>("/api/qr/preview", {
method: "POST",
json: requestBody,
signal: controller.signal,
})
.then((result) => {
if (id !== requestId.current) return;
setPreview(result);
setPreviewError(null);
})
.catch((error: unknown) => {
if (controller.signal.aborted || id !== requestId.current) return;
setPreview(null);
setPreviewError(
error instanceof ApiError
? error.message
: "Die Vorschau konnte nicht geladen werden.",
);
})
.finally(() => {
if (id === requestId.current) setPreviewLoading(false);
});
}, 250);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [ready, requestBody]);
/* ------------------------------------------------------------ Download */
const downloadDisabledReason =
isDynamic && !saved
? "Speichere den dynamischen Code, damit die Kurz-URL entsteht — danach ist der Download möglich."
: null;
const handleDownload = useCallback(
async (format: "png" | "svg") => {
setDownloadError(null);
setDownloading(format);
try {
await downloadFile(
"/api/qr/download",
{ ...requestBody, format },
`qr-code.${format}`,
);
} catch (error) {
setDownloadError(
error instanceof ApiError
? error.message
: "Der Download ist fehlgeschlagen.",
);
} finally {
setDownloading(null);
}
},
[requestBody],
);
/* ----------------------------------------------- Dynamischen Code speichern */
const hasUnsavedChanges =
saved !== null &&
(!sameData(saved.data, validation.ok ? validation.data : data) ||
!sameData(saved.design, design) ||
(saved.label ?? "") !== label.trim());
const handleSave = useCallback(async () => {
if (!validation.ok || !designValid) return;
setSaving(true);
setSaveError(null);
try {
const body = {
type: typeId,
data: validation.data,
design,
label: label.trim() || undefined,
};
const result = saved
? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, {
method: "PATCH",
json: { data: validation.data, design, label: label.trim() || null },
})
: await apiJson<{ code: QrCodeDto }>("/api/codes", {
method: "POST",
json: body,
});
setSaved(result.code);
} catch (error) {
setSaveError(
error instanceof ApiError
? error.message
: "Der Code konnte nicht gespeichert werden.",
);
} finally {
setSaving(false);
}
}, [validation, designValid, typeId, design, label, saved]);
const handleCopy = useCallback(async () => {
if (!saved) return;
try {
await navigator.clipboard.writeText(saved.shortUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
setCopied(false);
}
}, [saved]);
/* ---------------------------------------------------------------- View */
return (
<div className="mx-auto max-w-6xl 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" />
Alle Typen
</Link>
<div className="mb-7 flex flex-wrap items-center gap-3">
<span className="flex size-11 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600">
<Icon name={module.icon} className="size-6" />
</span>
<div>
<h1 className="text-2xl font-bold tracking-tight text-slate-900">
{module.title}-QR-Code erstellen
</h1>
<p className="text-sm text-slate-600">{module.description}</p>
</div>
</div>
<div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-[minmax(0,1fr)_380px]">
{/* -------------------------------------------------- linke Spalte */}
<div className="space-y-6">
<Card>
<CardTitle>Inhalt</CardTitle>
{Form ? (
<Form value={data} onChange={setData} error={dataError} />
) : (
<Notice tone="error">
Für diesen Typ ist noch kein Formular hinterlegt.
</Notice>
)}
</Card>
<Card>
<CardTitle
hint="Statisch: die Adresse steckt direkt im Code und lässt sich später nicht mehr ändern. Dynamisch: der Code zeigt auf eine Kurz-URL, deren Ziel du jederzeit anpassen kannst."
>
Art des Codes
</CardTitle>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<button
type="button"
aria-pressed={!isDynamic}
onClick={() => setIsDynamic(false)}
className={cx(
"rounded-xl border p-4 text-left transition-colors",
!isDynamic
? "border-indigo-600 bg-indigo-50/60"
: "border-slate-300 bg-white hover:bg-slate-50",
)}
>
<span className="flex items-center gap-2 font-medium text-slate-900">
<Icon name="lock" className="size-4" />
Statisch
</span>
<span className="mt-1 block text-sm text-slate-600">
Funktioniert ohne diesen Server, dafür unveränderlich.
</span>
</button>
<button
type="button"
aria-pressed={isDynamic}
disabled={!module.supportsDynamic}
onClick={() => setIsDynamic(true)}
className={cx(
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
isDynamic
? "border-indigo-600 bg-indigo-50/60"
: "border-slate-300 bg-white hover:bg-slate-50",
)}
>
<span className="flex items-center gap-2 font-medium text-slate-900">
<Icon name="scan" className="size-4" />
Dynamisch
<Badge tone="neutral">mit Statistik</Badge>
</span>
<span className="mt-1 block text-sm text-slate-600">
Ziel jederzeit änderbar, Scans werden gezählt.
</span>
</button>
</div>
{isDynamic ? (
<div className="mt-5 space-y-4 border-t border-slate-200 pt-5">
<Field
label="Name (optional)"
htmlFor="code-label"
hint="Hilft dir, den Code später in „Meine Codes“ wiederzufinden."
>
<TextInput
id="code-label"
value={label}
maxLength={120}
placeholder="z. B. Plakat Eingang"
onChange={(event) => setLabel(event.target.value)}
/>
</Field>
{saved ? (
<div className="space-y-3">
<Notice tone="success">
<span className="font-medium">Code gespeichert.</span> Der
QR-Code zeigt dauerhaft auf diese Kurz-URL:
<span className="mt-2 flex flex-wrap items-center gap-2">
<code className="rounded bg-white/70 px-2 py-1 font-mono text-xs break-all">
{saved.shortUrl}
</code>
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleCopy}
>
<Icon
name={copied ? "check" : "copy"}
className="size-3.5"
/>
{copied ? "Kopiert" : "Kopieren"}
</Button>
</span>
</Notice>
<div className="flex flex-wrap items-center gap-3">
{hasUnsavedChanges ? (
<Button
type="button"
onClick={handleSave}
disabled={saving || !ready}
>
{saving ? "Speichert …" : "Änderungen speichern"}
</Button>
) : null}
<Link
href="/meine-codes"
className="inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-indigo-600 hover:text-indigo-700"
>
Zu „Meine Codes"
<Icon name="external" className="size-3.5" />
</Link>
</div>
{hasUnsavedChanges ? (
<p className="text-sm text-amber-700">
Du hast Änderungen vorgenommen, die noch nicht gespeichert
sind. Der gedruckte Code bleibt gültig — nur das Ziel
ändert sich.
</p>
) : null}
</div>
) : (
<div className="space-y-3">
<Notice tone="info">
Die Vorschau zeigt einen Platzhalter. Die endgültige
Kurz-URL — und damit der fertige QR-Code — entsteht beim
Speichern.
</Notice>
<Button
type="button"
onClick={handleSave}
disabled={saving || !ready}
>
{saving ? "Speichert …" : "Dynamischen Code speichern"}
</Button>
</div>
)}
{saveError ? <Notice tone="error">{saveError}</Notice> : null}
</div>
) : null}
</Card>
<Card>
<CardTitle hint="Farben, Größe und Fehlerkorrektur gelten für Vorschau und Download.">
Gestaltung
</CardTitle>
<DesignPanel design={design} onChange={setDesign} />
</Card>
</div>
{/* ------------------------------------------------- rechte Spalte */}
<div className="lg:sticky lg:top-6">
<Card>
<CardTitle>Vorschau</CardTitle>
<QrPreview
dataUrl={preview?.dataUrl ?? null}
content={preview?.content ?? null}
size={design.size}
error={previewError ?? downloadError}
loading={previewLoading}
downloadDisabledReason={downloadDisabledReason}
onDownload={handleDownload}
downloading={downloading}
/>
</Card>
</div>
</div>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { Icon } from "@/components/icons";
import { Button, Notice } from "@/components/ui";
export function QrPreview({
dataUrl,
content,
size,
error,
loading,
downloadDisabledReason,
onDownload,
downloading,
}: {
dataUrl: string | null;
content: string | null;
size: number;
error: string | null;
loading: boolean;
downloadDisabledReason: string | null;
onDownload: (format: "png" | "svg") => void;
downloading: "png" | "svg" | null;
}) {
return (
<div className="space-y-4">
<div
className="qr-canvas relative grid aspect-square w-full place-items-center overflow-hidden rounded-xl border border-slate-200 p-4"
aria-busy={loading}
>
{dataUrl ? (
/* eslint-disable-next-line @next/next/no-img-element -- Data-URL, keine Optimierung nötig oder möglich */
<img
src={dataUrl}
alt="Vorschau des QR-Codes"
className="h-full w-full object-contain"
/>
) : (
<div className="px-6 text-center text-sm text-slate-400">
<Icon name="qr" className="mx-auto mb-2 size-10" />
{error ? "Keine Vorschau verfügbar" : "Gib Daten ein, um die Vorschau zu sehen"}
</div>
)}
{loading ? (
<span className="absolute top-3 right-3 rounded-full bg-white/90 px-2 py-0.5 text-xs font-medium text-slate-500 shadow-sm">
aktualisiert …
</span>
) : null}
</div>
{error ? <Notice tone="error">{error}</Notice> : null}
{content ? (
<div className="rounded-lg bg-slate-50 px-3 py-2.5">
<p className="text-xs font-medium text-slate-500">Kodierter Inhalt</p>
<p className="mt-0.5 font-mono text-xs break-all text-slate-700">
{content}
</p>
</div>
) : null}
<div className="grid grid-cols-2 gap-3">
<Button
type="button"
variant="primary"
disabled={!dataUrl || Boolean(downloadDisabledReason) || downloading !== null}
onClick={() => onDownload("png")}
>
<Icon name="download" className="size-4" />
{downloading === "png" ? "PNG …" : "PNG"}
</Button>
<Button
type="button"
variant="secondary"
disabled={!dataUrl || Boolean(downloadDisabledReason) || downloading !== null}
onClick={() => onDownload("svg")}
>
<Icon name="download" className="size-4" />
{downloading === "svg" ? "SVG …" : "SVG"}
</Button>
</div>
{downloadDisabledReason ? (
<p className="text-center text-xs text-slate-500">
{downloadDisabledReason}
</p>
) : (
<p className="text-center text-xs text-slate-500">
PNG mit {size} × {size} px · SVG frei skalierbar
</p>
)}
</div>
);
}
+182
View File
@@ -0,0 +1,182 @@
import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode } from "react";
export function cx(...classes: Array<string | false | null | undefined>): string {
return classes.filter(Boolean).join(" ");
}
/* -------------------------------------------------------------------- Card */
export function Card({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<section
className={cx(
"rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6",
className,
)}
>
{children}
</section>
);
}
export function CardTitle({
children,
hint,
}: {
children: ReactNode;
hint?: ReactNode;
}) {
return (
<div className="mb-4">
<h2 className="text-base font-semibold text-slate-900">{children}</h2>
{hint ? <p className="mt-1 text-sm text-slate-500">{hint}</p> : null}
</div>
);
}
/* ------------------------------------------------------------------ Button */
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "ghost" | "danger";
size?: "md" | "sm";
};
const BUTTON_VARIANTS: Record<NonNullable<ButtonProps["variant"]>, string> = {
primary:
"bg-indigo-600 text-white hover:bg-indigo-700 disabled:hover:bg-indigo-600",
secondary:
"border border-slate-300 bg-white text-slate-700 hover:bg-slate-50 disabled:hover:bg-white",
ghost: "text-slate-600 hover:bg-slate-100 disabled:hover:bg-transparent",
danger: "bg-red-600 text-white hover:bg-red-700 disabled:hover:bg-red-600",
};
export function Button({
variant = "primary",
size = "md",
className,
...props
}: ButtonProps) {
return (
<button
className={cx(
"inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50",
size === "md" ? "px-4 py-2.5 text-sm" : "px-3 py-1.5 text-sm",
BUTTON_VARIANTS[variant],
className,
)}
{...props}
/>
);
}
/* ------------------------------------------------------------------- Field */
export function Field({
label,
hint,
error,
htmlFor,
children,
}: {
label: string;
hint?: ReactNode;
error?: string | null;
htmlFor?: string;
children: ReactNode;
}) {
return (
<div>
<label
htmlFor={htmlFor}
className="mb-1.5 block text-sm font-medium text-slate-700"
>
{label}
</label>
{children}
{error ? (
<p role="alert" className="mt-1.5 text-sm text-red-600">
{error}
</p>
) : hint ? (
<p className="mt-1.5 text-sm text-slate-500">{hint}</p>
) : null}
</div>
);
}
export function TextInput({
invalid,
className,
...props
}: InputHTMLAttributes<HTMLInputElement> & { invalid?: boolean }) {
return (
<input
className={cx(
"w-full rounded-lg border bg-white px-3 py-2.5 text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400",
invalid
? "border-red-400 focus-visible:outline-red-500"
: "border-slate-300",
className,
)}
{...props}
/>
);
}
/* ------------------------------------------------------------------- Badge */
export function Badge({
tone = "neutral",
children,
}: {
tone?: "neutral" | "popular" | "soon" | "success";
children: ReactNode;
}) {
const tones: Record<string, string> = {
neutral: "bg-slate-100 text-slate-600",
popular: "bg-indigo-100 text-indigo-700",
soon: "bg-slate-100 text-slate-500",
success: "bg-emerald-100 text-emerald-700",
};
return (
<span
className={cx(
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
tones[tone],
)}
>
{children}
</span>
);
}
/* ------------------------------------------------------------------ Notice */
export function Notice({
tone,
children,
}: {
tone: "warning" | "error" | "info" | "success";
children: ReactNode;
}) {
const tones: Record<string, string> = {
warning: "border-amber-300 bg-amber-50 text-amber-900",
error: "border-red-300 bg-red-50 text-red-800",
info: "border-sky-300 bg-sky-50 text-sky-900",
success: "border-emerald-300 bg-emerald-50 text-emerald-900",
};
return (
<div
role={tone === "error" ? "alert" : "status"}
className={cx("rounded-lg border px-3.5 py-3 text-sm", tones[tone])}
>
{children}
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
/** Kleine Hilfen für Aufrufe der eigenen API aus Client-Komponenten. */
async function readError(response: Response): Promise<string> {
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;
}
} catch {
// Antwort ohne JSON-Körper — unten kommt die allgemeine Meldung.
}
return `Die Anfrage ist fehlgeschlagen (HTTP ${response.status}).`;
}
export class ApiError extends Error {}
export async function apiJson<T>(
url: string,
init?: RequestInit & { json?: unknown },
): Promise<T> {
const { json, ...rest } = init ?? {};
const response = await fetch(url, {
...rest,
headers:
json === undefined
? rest.headers
: { "Content-Type": "application/json", ...rest.headers },
body: json === undefined ? rest.body : JSON.stringify(json),
});
if (!response.ok) {
throw new ApiError(await readError(response));
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
/** Holt eine Datei von der API und stößt den Browser-Download an. */
export async function downloadFile(
url: string,
json: unknown,
fallbackName: string,
): Promise<void> {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(json),
});
if (!response.ok) {
throw new ApiError(await readError(response));
}
const disposition = response.headers.get("Content-Disposition") ?? "";
const match = /filename="([^"]+)"/.exec(disposition);
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = match?.[1] ?? fallbackName;
document.body.append(anchor);
anchor.click();
anchor.remove();
// Der Browser braucht den Blob noch einen Moment für den Download.
setTimeout(() => URL.revokeObjectURL(objectUrl), 10_000);
}
+69
View File
@@ -0,0 +1,69 @@
import "server-only";
import type { Prisma, QrCode } from "@/generated/prisma/client";
import type { QrCodeDto } from "@/lib/dto";
import { shortUrl } from "@/lib/env";
import type { EcLevel } from "@/lib/qr/design";
import { getQrType } from "@/lib/qr/registry";
import type { QrTypeId } from "@/lib/qr/type-module";
export type { QrCodeDto };
/**
* Die Typ-Module liefern validierte, JSON-serialisierbare Objekte. Prisma
* kann das aus `Record<string, unknown>` nicht ableiten, deshalb hier die
* einzige Stelle mit einer expliziten Umdeutung.
*/
export function toJsonInput(
data: Record<string, unknown>,
): Prisma.InputJsonValue {
return data as Prisma.InputJsonValue;
}
function asRecord(value: unknown): Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
export function toQrCodeDto(code: QrCode): QrCodeDto {
const data = asRecord(code.data);
const module = getQrType(code.type);
let target = "";
let summary = "";
if (module) {
try {
target = module.redirectTarget(data);
summary = module.summary(data);
} catch {
// Datensatz passt nicht mehr zum Typ-Schema (z. B. nach einem
// Schema-Wechsel). Die Liste soll trotzdem anzeigbar bleiben.
summary = "Ungültige Daten";
}
} else {
summary = "Unbekannter Typ";
}
return {
id: code.id,
slug: code.slug,
type: code.type as QrTypeId,
label: code.label,
data,
shortUrl: shortUrl(code.slug),
target,
summary,
design: {
fgColor: code.fgColor,
bgColor: code.bgColor,
size: code.size,
ecLevel: code.ecLevel as EcLevel,
margin: code.margin,
},
scans: code.scans,
lastScanAt: code.lastScanAt?.toISOString() ?? null,
createdAt: code.createdAt.toISOString(),
updatedAt: code.updatedAt.toISOString(),
};
}
+24
View File
@@ -0,0 +1,24 @@
import "server-only";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
import { getDatabaseUrl } from "@/lib/env";
// Im Entwicklungsmodus wird das Modul bei jedem Hot-Reload neu ausgewertet.
// Ohne diesen Cache entstünde pro Reload ein neuer Verbindungspool.
const globalForPrisma = globalThis as unknown as {
prisma?: ReturnType<typeof createPrismaClient>;
};
function createPrismaClient() {
return new PrismaClient({
adapter: new PrismaPg({ connectionString: getDatabaseUrl() }),
});
}
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
+31
View File
@@ -0,0 +1,31 @@
import type { EcLevel } from "@/lib/qr/design";
import type { QrTypeId } from "@/lib/qr/type-module";
/**
* Darstellung eines dynamischen Codes an der API-Grenze. Bewusst frei von
* Server-Abhängigkeiten, damit Client-Komponenten den Typ verwenden können.
*/
export type QrCodeDto = {
id: string;
slug: string;
type: QrTypeId;
label: string | null;
data: Record<string, unknown>;
/** Die Kurz-URL, die im QR-Code steckt. */
shortUrl: string;
/** Aktuelles Weiterleitungsziel. */
target: string;
/** Lesbare Kurzfassung des Ziels. */
summary: string;
design: {
fgColor: string;
bgColor: string;
size: number;
ecLevel: EcLevel;
margin: number;
};
scans: number;
lastScanAt: string | null;
createdAt: string;
updatedAt: string;
};
+31
View File
@@ -0,0 +1,31 @@
/**
* Zugriff auf die Laufzeit-Konfiguration. Die Werte werden bewusst erst beim
* Aufruf gelesen, damit `docker compose up` sie ohne Neu-Build ändern kann.
*/
const FALLBACK_BASE_URL = "http://localhost:3000";
/**
* Öffentliche Adresse der App. Sie landet in jedem dynamischen QR-Code und
* muss deshalb von den scannenden Geräten erreichbar sein.
*/
export function getBaseUrl(): string {
const raw = process.env.BASE_URL?.trim();
if (!raw) return FALLBACK_BASE_URL;
return raw.replace(/\/+$/, "");
}
/** Kurz-URL eines dynamischen Codes. */
export function shortUrl(slug: string): string {
return `${getBaseUrl()}/r/${slug}`;
}
export function getDatabaseUrl(): string {
const url = process.env.DATABASE_URL?.trim();
if (!url) {
throw new Error(
"DATABASE_URL ist nicht gesetzt. Bitte .env anlegen (Vorlage: .env.example).",
);
}
return url;
}
+33
View File
@@ -0,0 +1,33 @@
import { QrRenderError } from "@/lib/qr/render";
import { QrRequestError } from "@/lib/qr/request";
import { QrTypeError } from "@/lib/qr/type-module";
export function jsonError(message: string, status = 400): Response {
return Response.json({ error: message }, { status });
}
/**
* Übersetzt eine Ausnahme in eine Antwort mit deutscher Fehlermeldung.
* Unerwartete Fehler werden protokolliert, nach außen aber generisch
* gemeldet.
*/
export function errorResponse(error: unknown): Response {
if (error instanceof QrRequestError) {
return jsonError(error.message, error.status);
}
if (error instanceof QrRenderError || error instanceof QrTypeError) {
return jsonError(error.message, 400);
}
console.error("Unerwarteter Fehler in einer API-Route:", error);
return jsonError(
"Es ist ein unerwarteter Fehler aufgetreten. Bitte versuche es erneut.",
500,
);
}
/** Erste Fehlermeldung eines fehlgeschlagenen Zod-Parsers. */
export function firstZodMessage(error: {
issues: ReadonlyArray<{ message: string }>;
}): string {
return error.issues[0]?.message ?? "Die Anfrage ist ungültig.";
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Kontrastbewertung für QR-Codes.
*
* Scanner erkennen einen Code über den Helligkeitsunterschied zwischen
* Modul- und Hintergrundfarbe. Zu geringer Kontrast — und ebenso ein heller
* Vordergrund auf dunklem Hintergrund („invertierter" Code) — führt bei
* vielen Kameras zu Lesefehlern.
*/
export type ContrastVerdict = "gut" | "grenzwertig" | "schlecht";
export type ContrastResult = {
ratio: number;
verdict: ContrastVerdict;
/** Vordergrund ist heller als der Hintergrund. */
inverted: boolean;
/** Hinweistext, falls es etwas zu beanstanden gibt. */
message: string | null;
};
function parseHex(hex: string): [number, number, number] | null {
const match = /^#([0-9a-fA-F]{6})$/.exec(hex.trim());
if (!match) return null;
const value = Number.parseInt(match[1], 16);
return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff];
}
/** Relative Luminanz nach WCAG 2.1. */
function relativeLuminance([r, g, b]: [number, number, number]): number {
const channel = (raw: number) => {
const c = raw / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
}
export function evaluateContrast(fgColor: string, bgColor: string): ContrastResult {
const fg = parseHex(fgColor);
const bg = parseHex(bgColor);
if (!fg || !bg) {
return { ratio: 0, verdict: "schlecht", inverted: false, message: null };
}
const fgLuminance = relativeLuminance(fg);
const bgLuminance = relativeLuminance(bg);
const lighter = Math.max(fgLuminance, bgLuminance);
const darker = Math.min(fgLuminance, bgLuminance);
const ratio = (lighter + 0.05) / (darker + 0.05);
const inverted = fgLuminance > bgLuminance;
let verdict: ContrastVerdict;
if (ratio < 3) verdict = "schlecht";
else if (ratio < 7) verdict = "grenzwertig";
else verdict = "gut";
let message: string | null = null;
if (verdict === "schlecht") {
message =
"Der Kontrast ist zu gering — dieser Code lässt sich vermutlich nicht zuverlässig scannen. Wähle einen dunklen Vordergrund auf hellem Hintergrund.";
} else if (inverted) {
message =
"Heller Vordergrund auf dunklem Hintergrund: viele Scanner-Apps erkennen solche invertierten Codes nicht. Besser die Farben tauschen.";
} else if (verdict === "grenzwertig") {
message =
"Der Kontrast ist grenzwertig. Auf kleinen Ausdrucken oder bei schlechtem Licht kann das Scannen fehlschlagen.";
}
return { ratio, verdict, inverted, message };
}
+65
View File
@@ -0,0 +1,65 @@
import { z } from "zod";
export const EC_LEVELS = ["L", "M", "Q", "H"] as const;
export type EcLevel = (typeof EC_LEVELS)[number];
export const EC_LEVEL_LABELS: Record<EcLevel, string> = {
L: "L — niedrig (7 % wiederherstellbar)",
M: "M — mittel (15 %, Standard)",
Q: "Q — hoch (25 %)",
H: "H — sehr hoch (30 %)",
};
export const MIN_SIZE = 256;
export const MAX_SIZE = 2048;
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
const hexColor = z
.string()
.trim()
.regex(HEX_COLOR, {
error: "Bitte eine Farbe im Format #RRGGBB angeben, z. B. #1E293B.",
})
.transform((value) => value.toUpperCase());
export const designSchema = z.object({
fgColor: hexColor,
bgColor: hexColor,
size: z.coerce
.number({ error: "Die Größe muss eine Zahl sein." })
.int({ error: "Die Größe muss eine ganze Zahl sein." })
.min(MIN_SIZE, { error: `Die Größe muss mindestens ${MIN_SIZE} px betragen.` })
.max(MAX_SIZE, { error: `Die Größe darf höchstens ${MAX_SIZE} px betragen.` }),
ecLevel: z.enum(EC_LEVELS, {
error: "Ungültiges Fehlerkorrektur-Level. Erlaubt sind L, M, Q und H.",
}),
margin: z.coerce
.number({ error: "Der Rand muss eine Zahl sein." })
.int({ error: "Der Rand muss eine ganze Zahl sein." })
.min(0, { error: "Der Rand darf nicht negativ sein." })
.max(16, { error: "Der Rand darf höchstens 16 Module breit sein." }),
});
export type QrDesign = z.infer<typeof designSchema>;
export const defaultDesign: QrDesign = {
fgColor: "#0F172A",
bgColor: "#FFFFFF",
size: 512,
ecLevel: "M",
margin: 4,
};
/** Farbvorschläge für die schnelle Auswahl in der Oberfläche. */
export const COLOR_PRESETS: ReadonlyArray<{
label: string;
fgColor: string;
bgColor: string;
}> = [
{ label: "Klassisch", fgColor: "#0F172A", bgColor: "#FFFFFF" },
{ label: "Indigo", fgColor: "#3730A3", bgColor: "#FFFFFF" },
{ label: "Waldgrün", fgColor: "#14532D", bgColor: "#F0FDF4" },
{ label: "Bordeaux", fgColor: "#7F1D1D", bgColor: "#FFF7ED" },
{ label: "Marine", fgColor: "#1E3A8A", bgColor: "#EFF6FF" },
];
+130
View File
@@ -0,0 +1,130 @@
import {
defineComingSoonType,
isQrTypeId,
QR_TYPE_IDS,
type QrTypeId,
type QrTypeModule,
} from "@/lib/qr/type-module";
import { websiteType } from "@/lib/qr/types/website";
/**
* Zentrale Registry aller QR-Typen. Die Reihenfolge bestimmt die Reihenfolge
* der Kacheln auf der Startseite.
*
* Ein neuer Typ in V2: den `defineComingSoonType`-Eintrag durch ein echtes
* Modul (siehe `types/website.ts`) ersetzen und die zugehörige Formular-
* Komponente in `src/components/qr-forms/index.tsx` registrieren. Am
* restlichen Code ändert sich nichts.
*/
const MODULES: readonly QrTypeModule[] = [
websiteType,
defineComingSoonType({
id: "vcard",
title: "vCard",
description: "Digitale Visitenkarte mit Kontaktdaten.",
icon: "contact",
}),
defineComingSoonType({
id: "app",
title: "App-Link",
description: "Leite je nach Gerät in den passenden App-Store.",
icon: "smartphone",
}),
defineComingSoonType({
id: "links",
title: "Mehrere Links",
description: "Eine Landingpage mit mehreren Zielen.",
icon: "list",
}),
defineComingSoonType({
id: "social",
title: "Social Media",
description: "Bündle deine Profile in einem Code.",
icon: "share",
}),
defineComingSoonType({
id: "whatsapp",
title: "WhatsApp",
description: "Starte einen Chat mit vorbereiteter Nachricht.",
icon: "chat",
}),
defineComingSoonType({
id: "pdf",
title: "PDF",
description: "Stelle ein PDF-Dokument zum Abruf bereit.",
icon: "document",
}),
defineComingSoonType({
id: "image",
title: "Bild",
description: "Zeige ein Bild oder eine Galerie.",
icon: "image",
}),
defineComingSoonType({
id: "video",
title: "Video",
description: "Verlinke ein Video oder einen Stream.",
icon: "video",
}),
defineComingSoonType({
id: "email",
title: "E-Mail",
description: "Öffne eine vorbereitete E-Mail.",
icon: "mail",
}),
defineComingSoonType({
id: "sms",
title: "SMS",
description: "Öffne eine vorbereitete Kurznachricht.",
icon: "message",
}),
defineComingSoonType({
id: "location",
title: "Standort",
description: "Zeige einen Ort auf der Karte.",
icon: "pin",
}),
defineComingSoonType({
id: "payment",
title: "Zahlung",
description: "Fordere eine Zahlung per QR-Code an.",
icon: "card",
}),
defineComingSoonType({
id: "wifi",
title: "WLAN",
description: "Verbinde Gäste ohne Passworteingabe.",
icon: "wifi",
}),
defineComingSoonType({
id: "feedback",
title: "Feedback",
description: "Sammle Bewertungen und Rückmeldungen.",
icon: "star",
}),
];
export const qrTypes: Record<QrTypeId, QrTypeModule> = Object.fromEntries(
MODULES.map((module) => [module.id, module]),
) as Record<QrTypeId, QrTypeModule>;
const missing = QR_TYPE_IDS.filter((id) => !qrTypes[id]);
if (missing.length > 0) {
throw new Error(`QR-Typ-Registry unvollständig: ${missing.join(", ")}`);
}
/** Alle Typen in Anzeigereihenfolge. */
export const qrTypeList: readonly QrTypeModule[] = MODULES;
export function getQrType(id: string): QrTypeModule | undefined {
return isQrTypeId(id) ? qrTypes[id] : undefined;
}
/** Liefert den Typ nur, wenn er in dieser Version nutzbar ist. */
export function getActiveQrType(id: string): QrTypeModule | undefined {
const module = getQrType(id);
return module?.active ? module : undefined;
}
export { QR_TYPE_IDS, isQrTypeId };
export type { QrTypeId, QrTypeModule };
+59
View File
@@ -0,0 +1,59 @@
import "server-only";
import QRCode, { type QRCodeRenderersOptions } from "qrcode";
import type { QrDesign } from "@/lib/qr/design";
/** Fehler beim Erzeugen des Codes, mit Meldung für die Oberfläche. */
export class QrRenderError extends Error {}
function toOptions(design: QrDesign): QRCodeRenderersOptions {
return {
errorCorrectionLevel: design.ecLevel,
margin: design.margin,
width: design.size,
color: {
dark: design.fgColor,
light: design.bgColor,
},
};
}
function wrap(error: unknown): never {
const detail = error instanceof Error ? error.message : String(error);
if (/too (long|big)|code length overflow|data too long/i.test(detail)) {
throw new QrRenderError(
"Der Inhalt ist zu lang für einen QR-Code. Kürze die Adresse oder wähle ein niedrigeres Fehlerkorrektur-Level.",
);
}
throw new QrRenderError(`Der QR-Code konnte nicht erzeugt werden: ${detail}`);
}
export async function renderSvg(
content: string,
design: QrDesign,
): Promise<string> {
try {
return await QRCode.toString(content, { ...toOptions(design), type: "svg" });
} catch (error) {
wrap(error);
}
}
export async function renderPng(
content: string,
design: QrDesign,
): Promise<Buffer> {
try {
return await QRCode.toBuffer(content, { ...toOptions(design), type: "png" });
} catch (error) {
wrap(error);
}
}
export type QrFormat = "png" | "svg";
export const CONTENT_TYPES: Record<QrFormat, string> = {
png: "image/png",
svg: "image/svg+xml",
};
+115
View File
@@ -0,0 +1,115 @@
import { z } from "zod";
import { designSchema } from "@/lib/qr/design";
import { getActiveQrType } from "@/lib/qr/registry";
import { QR_TYPE_IDS, type QrTypeModule } from "@/lib/qr/type-module";
import { shortUrl } from "@/lib/env";
import { isValidSlug } from "@/lib/slug";
/**
* Platzhalter-Slug für die Vorschau eines noch nicht gespeicherten
* dynamischen Codes. Gleiche Länge wie ein echter Slug, damit die Vorschau
* die spätere Modul-Dichte korrekt zeigt.
*/
export const PREVIEW_SLUG = "vorschau";
export const qrModeSchema = z.enum(["static", "dynamic"], {
error: "Ungültiger Modus.",
});
export type QrMode = z.infer<typeof qrModeSchema>;
export const qrRequestSchema = z.object({
type: z.enum(QR_TYPE_IDS, { error: "Unbekannter QR-Typ." }),
data: z.record(z.string(), z.unknown()),
design: designSchema,
mode: qrModeSchema.default("static"),
/** Slug eines bereits gespeicherten dynamischen Codes. */
slug: z.string().trim().optional(),
});
export type QrRequest = z.infer<typeof qrRequestSchema>;
/** Fehler mit einer Meldung, die unverändert an die Oberfläche geht. */
export class QrRequestError extends Error {
constructor(
message: string,
readonly status: number = 400,
) {
super(message);
}
}
export type ResolvedQrRequest = {
module: QrTypeModule;
data: Record<string, unknown>;
/** Der Text, der tatsächlich in den QR-Code kodiert wird. */
content: string;
};
/**
* Prüft eine Anfrage vollständig und liefert den zu kodierenden Inhalt:
* bei statischen Codes die Nutzdaten selbst, bei dynamischen die Kurz-URL.
*/
export function resolveQrRequest(request: QrRequest): ResolvedQrRequest {
const module = getActiveQrType(request.type);
if (!module) {
throw new QrRequestError(
"Dieser QR-Typ ist in dieser Version noch nicht verfügbar.",
404,
);
}
const validated = module.validate(request.data);
if (!validated.ok) {
throw new QrRequestError(validated.message);
}
if (request.mode === "dynamic") {
if (!module.supportsDynamic) {
throw new QrRequestError(
`Der QR-Typ „${module.title}" unterstützt keine dynamischen Codes.`,
);
}
const slug = request.slug;
if (slug !== undefined && !isValidSlug(slug)) {
throw new QrRequestError("Ungültige Kurz-URL.");
}
return {
module,
data: validated.data,
content: shortUrl(slug ?? PREVIEW_SLUG),
};
}
return {
module,
data: validated.data,
content: module.encode(validated.data),
};
}
/** Dateiname für den Download, abgeleitet aus dem Inhalt des Codes. */
export function downloadFilename(
module: QrTypeModule,
data: unknown,
extension: string,
): string {
let base = "qr-code";
try {
const summary = module
.summary(data)
.toLowerCase()
.replaceAll("ä", "ae")
.replaceAll("ö", "oe")
.replaceAll("ü", "ue")
.replaceAll("ß", "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
if (summary) base = `qr-${summary}`;
} catch {
// Zusammenfassung ist optional — im Zweifel bleibt der Standardname.
}
return `${base}.${extension}`;
}
+171
View File
@@ -0,0 +1,171 @@
import type { z } from "zod";
import type { IconName } from "@/components/icons";
/**
* Alle QR-Typen, die die Anwendung kennt. V1 implementiert davon nur
* "website"; die übrigen Einträge sind als Platzhalter registriert und
* erscheinen in der Oberfläche mit dem Badge „Bald verfügbar".
*/
export const QR_TYPE_IDS = [
"website",
"vcard",
"app",
"links",
"social",
"whatsapp",
"pdf",
"image",
"video",
"email",
"sms",
"location",
"payment",
"wifi",
"feedback",
] as const;
export type QrTypeId = (typeof QR_TYPE_IDS)[number];
export function isQrTypeId(value: unknown): value is QrTypeId {
return (
typeof value === "string" && (QR_TYPE_IDS as readonly string[]).includes(value)
);
}
/** Die typspezifischen Formulardaten sind immer ein flaches Objekt. */
export type QrFormData = Record<string, unknown>;
export type ValidationResult =
| { ok: true; data: QrFormData }
| { ok: false; message: string };
/**
* Erzeugungs-Fehler mit einer Meldung, die direkt in der Oberfläche
* angezeigt werden kann.
*/
export class QrTypeError extends Error {}
/**
* Die Schnittstelle, über die der Rest der Anwendung mit einem QR-Typ
* arbeitet. Die konkreten Datentypen sind hier bewusst gelöscht: die
* Registry ist eine Dispatch-Tabelle über heterogene Typen. Typsicherheit
* entsteht innerhalb des jeweiligen Moduls durch `defineQrType`.
*/
export interface QrTypeModule {
readonly id: QrTypeId;
readonly title: string;
readonly description: string;
readonly icon: IconName;
/** Ist der Typ in dieser Version nutzbar? */
readonly active: boolean;
/** Unterstützt der Typ dynamische Codes (Kurz-URL + Weiterleitung)? */
readonly supportsDynamic: boolean;
/** Startwerte für ein leeres Formular. */
readonly defaultData: QrFormData;
/** Prüft Eingaben und liefert im Fehlerfall eine deutsche Meldung. */
validate(input: unknown): ValidationResult;
/** Inhalt, der bei einem statischen Code in den QR-Code kodiert wird. */
encode(data: unknown): string;
/** Ziel, auf das ein dynamischer Code weiterleitet. */
redirectTarget(data: unknown): string;
/** Einzeilige Zusammenfassung für Listen und Übersichten. */
summary(data: unknown): string;
}
type QrTypeSpec<TSchema extends z.ZodType> = {
id: QrTypeId;
title: string;
description: string;
icon: IconName;
active: boolean;
supportsDynamic: boolean;
schema: TSchema;
defaultData: QrFormData;
/** Inhalt des statischen QR-Codes. */
encode: (data: z.output<TSchema>) => string;
/**
* Weiterleitungsziel eines dynamischen Codes. Fällt auf `encode` zurück —
* für URL-basierte Typen ist beides identisch.
*/
redirectTarget?: (data: z.output<TSchema>) => string;
summary: (data: z.output<TSchema>) => string;
};
function firstIssueMessage(error: z.ZodError): string {
return error.issues[0]?.message ?? "Die Eingabe ist ungültig.";
}
/**
* Baut aus einer typisierten Spezifikation ein Registry-Modul. Innerhalb des
* Moduls arbeitet man mit dem echten Datentyp (aus dem Zod-Schema abgeleitet),
* nach außen wird die generische `QrTypeModule`-Schnittstelle sichtbar.
*
* Ein neuer Typ in V2 besteht damit aus genau einem Aufruf dieser Funktion
* plus einer Formular-Komponente.
*/
export function defineQrType<TSchema extends z.ZodType>(
spec: QrTypeSpec<TSchema>,
): QrTypeModule {
const parse = (input: unknown): z.output<TSchema> => {
const result = spec.schema.safeParse(input);
if (!result.success) {
throw new QrTypeError(firstIssueMessage(result.error));
}
return result.data as z.output<TSchema>;
};
return {
id: spec.id,
title: spec.title,
description: spec.description,
icon: spec.icon,
active: spec.active,
supportsDynamic: spec.supportsDynamic,
defaultData: spec.defaultData,
validate(input) {
const result = spec.schema.safeParse(input);
return result.success
? { ok: true, data: result.data as QrFormData }
: { ok: false, message: firstIssueMessage(result.error) };
},
encode: (data) => spec.encode(parse(data)),
redirectTarget: (data) => (spec.redirectTarget ?? spec.encode)(parse(data)),
summary: (data) => spec.summary(parse(data)),
};
}
/**
* Platzhalter für einen noch nicht implementierten Typ. Er erscheint in der
* Typ-Auswahl, lässt sich aber nicht öffnen. In V2 wird der Aufruf hier
* einfach durch ein echtes `defineQrType`-Modul ersetzt.
*/
export function defineComingSoonType(spec: {
id: QrTypeId;
title: string;
description: string;
icon: IconName;
}): QrTypeModule {
const notAvailable = (): never => {
throw new QrTypeError(
`Der QR-Typ „${spec.title}" ist in dieser Version noch nicht verfügbar.`,
);
};
return {
id: spec.id,
title: spec.title,
description: spec.description,
icon: spec.icon,
active: false,
supportsDynamic: false,
defaultData: {},
validate: () => ({
ok: false,
message: `Der QR-Typ „${spec.title}" ist in dieser Version noch nicht verfügbar.`,
}),
encode: notAvailable,
redirectTarget: notAvailable,
summary: () => "Noch nicht verfügbar",
};
}
+56
View File
@@ -0,0 +1,56 @@
import { z } from "zod";
import { defineQrType } from "@/lib/qr/type-module";
/** Prüft, ob es sich um eine absolute http(s)-Adresse handelt. */
export function isHttpUrl(value: string): boolean {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
return false;
}
return (
(parsed.protocol === "http:" || parsed.protocol === "https:") &&
parsed.hostname.length > 0
);
}
export const websiteUrlSchema = z
.string({ error: "Bitte gib eine Adresse ein." })
.trim()
.min(1, { error: "Bitte gib eine Adresse ein." })
.max(2048, { error: "Die Adresse darf höchstens 2048 Zeichen lang sein." })
.refine(isHttpUrl, {
error:
"Das ist keine gültige Adresse. Sie muss mit http:// oder https:// beginnen, z. B. https://example.com",
});
export const websiteDataSchema = z.object({
url: websiteUrlSchema,
});
export type WebsiteData = z.infer<typeof websiteDataSchema>;
/** Zeigt in Listen die Domain gut lesbar an, mit der vollen URL als Zusatz. */
function describe(url: string): string {
try {
const parsed = new URL(url);
return parsed.hostname + (parsed.pathname === "/" ? "" : parsed.pathname);
} catch {
return url;
}
}
export const websiteType = defineQrType({
id: "website",
title: "Website",
description: "Verlinke eine beliebige Webadresse.",
icon: "globe",
active: true,
supportsDynamic: true,
schema: websiteDataSchema,
defaultData: { url: "" },
encode: (data) => data.url,
summary: (data) => describe(data.url),
});
+24
View File
@@ -0,0 +1,24 @@
import { randomInt } from "node:crypto";
/**
* Alphabet ohne leicht verwechselbare Zeichen (0/O, 1/l/I). Kurz-URLs werden
* gelegentlich abgetippt — Verwechslungen wären hier besonders ärgerlich.
*/
const ALPHABET = "23456789abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ";
export const SLUG_LENGTH = 8;
/** Muster, gegen das eingehende Slugs geprüft werden. */
export const SLUG_PATTERN = /^[0-9A-Za-z]{4,32}$/;
export function generateSlug(length: number = SLUG_LENGTH): string {
let slug = "";
for (let i = 0; i < length; i += 1) {
slug += ALPHABET[randomInt(ALPHABET.length)];
}
return slug;
}
export function isValidSlug(value: string): boolean {
return SLUG_PATTERN.test(value);
}