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