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);
}
}