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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user