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