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
+24
View File
@@ -0,0 +1,24 @@
# ---------------------------------------------------------------------------
# QR-Code-Generator — Konfiguration
# Diese Datei nach ".env" kopieren und anpassen:
# cp .env.example .env
# ---------------------------------------------------------------------------
# Öffentliche Adresse, unter der die App erreichbar ist. Sie wird in
# dynamische QR-Codes einkodiert ({BASE_URL}/r/{slug}) und muss deshalb von
# den Geräten erreichbar sein, die den Code scannen.
# Im LAN z. B.: http://192.168.1.50:3000
BASE_URL=http://localhost:3000
# Host-Port, unter dem die App veröffentlicht wird (Port-Mapping APP_PORT:3000).
APP_PORT=3000
# --- PostgreSQL ------------------------------------------------------------
POSTGRES_USER=qrcode
POSTGRES_PASSWORD=bitte-aendern
POSTGRES_DB=qrcode
# Verbindungsstring der App zur Datenbank.
# "db" ist der Servicename aus docker-compose.yml — innerhalb des Compose-
# Netzwerks wird darüber die Datenbank aufgelöst.
DATABASE_URL=postgresql://qrcode:bitte-aendern@db:5432/qrcode?schema=public
+45
View File
@@ -0,0 +1,45 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files — .env.example wird bewusst mitversioniert
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# Prisma Client wird beim Build generiert
/src/generated/prisma
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+14
View File
@@ -0,0 +1,14 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Erzeugt einen eigenständigen Server (.next/standalone) samt der
// tatsächlich benötigten node_modules — Grundlage für ein schlankes
// Laufzeit-Image im Dockerfile.
output: "standalone",
// qrcode und der Postgres-Treiber laufen ausschließlich serverseitig und
// sollen nicht vom Bundler verarbeitet werden.
serverExternalPackages: ["qrcode", "pg"],
};
export default nextConfig;
+8719
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "qrcode-generator",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@prisma/adapter-pg": "^7.9.1",
"@prisma/client": "^7.9.1",
"next": "16.3.0",
"pg": "^8.23.0",
"prisma": "^7.9.1",
"qrcode": "^1.5.4",
"react": "19.2.8",
"react-dom": "19.2.8",
"server-only": "^0.0.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/pg": "^8.21.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.4.2",
"eslint": "^9",
"eslint-config-next": "16.3.0",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+14
View File
@@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});
@@ -0,0 +1,25 @@
-- CreateTable
CREATE TABLE "QrCode" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"type" TEXT NOT NULL,
"label" TEXT,
"data" JSONB NOT NULL,
"fgColor" TEXT NOT NULL DEFAULT '#000000',
"bgColor" TEXT NOT NULL DEFAULT '#FFFFFF',
"size" INTEGER NOT NULL DEFAULT 512,
"ecLevel" TEXT NOT NULL DEFAULT 'M',
"margin" INTEGER NOT NULL DEFAULT 4,
"scans" INTEGER NOT NULL DEFAULT 0,
"lastScanAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "QrCode_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "QrCode_slug_key" ON "QrCode"("slug");
-- CreateIndex
CREATE INDEX "QrCode_createdAt_idx" ON "QrCode"("createdAt");
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+36
View File
@@ -0,0 +1,36 @@
// QR-Code-Generator — Datenmodell
// Doku: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
/// Ein dynamischer QR-Code. Der QR-Code kodiert `{BASE_URL}/r/{slug}` und
/// leitet beim Aufruf auf das Ziel weiter, das aus `data` aufgelöst wird.
///
/// `type` + `data` sind bewusst generisch gehalten: V1 kennt nur den Typ
/// "website" (`data = { url: "https://…" }`), V2-Typen (vCard, WLAN, …)
/// legen ihr eigenes Payload-Format in `data` ab, ohne Schemaänderung.
model QrCode {
id String @id @default(cuid())
slug String @unique
type String
label String?
data Json
fgColor String @default("#000000")
bgColor String @default("#FFFFFF")
size Int @default(512)
ecLevel String @default("M")
margin Int @default(4)
scans Int @default(0)
lastScanAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([createdAt])
}
+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);
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}