V1: Next.js-App mit QR-Typ-Registry, Website-Generator und dynamischen Codes
- Erweiterbare Registry aller 15 QR-Typen; nur "Website" ist aktiv, die
uebrigen erscheinen als Platzhalter mit Badge "Bald verfuegbar".
- defineQrType() kapselt Zod-Schema, Kodierung und Redirect-Ziel je Typ,
sodass ein V2-Typ nur aus einem Registry-Eintrag plus Formular besteht.
- Serverseitige QR-Erzeugung (PNG/SVG) mit Farben, Groesse, Fehlerkorrektur
und Rand; Live-Vorschau ueber /api/qr/preview.
- Dynamische Codes: Kurz-URL {BASE_URL}/r/{slug}, 302-Redirect mit
Scan-Zaehler, CRUD unter /api/codes.
- Prisma 7 mit pg-Driver-Adapter und Initial-Migration fuer PostgreSQL.
- Deutsche Oberflaeche mit Tailwind CSS, helles Theme, responsiv.
This commit is contained in:
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.";
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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 };
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user