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,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
@@ -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
|
||||||
@@ -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 -->
|
||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
Generated
+8719
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -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");
|
||||||
@@ -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"
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { shortUrl } from "@/lib/env";
|
||||||
|
import { errorResponse } from "@/lib/http";
|
||||||
|
import { designSchema } from "@/lib/qr/design";
|
||||||
|
import { getQrType } from "@/lib/qr/registry";
|
||||||
|
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
|
||||||
|
import { downloadFilename, QrRequestError } from "@/lib/qr/request";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Context = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendert einen gespeicherten dynamischen Code mit seiner hinterlegten
|
||||||
|
* Gestaltung. Wird für die Vorschaubilder in „Meine Codes“ und für die
|
||||||
|
* Download-Links dort verwendet.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
context: Context,
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const { id } = await context.params;
|
||||||
|
const format =
|
||||||
|
request.nextUrl.searchParams.get("format") === "png" ? "png" : "svg";
|
||||||
|
const isDownload = request.nextUrl.searchParams.get("download") === "1";
|
||||||
|
|
||||||
|
const code = await prisma.qrCode.findUnique({ where: { id } });
|
||||||
|
if (!code) {
|
||||||
|
throw new QrRequestError("Dieser QR-Code existiert nicht (mehr).", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const design = designSchema.parse({
|
||||||
|
fgColor: code.fgColor,
|
||||||
|
bgColor: code.bgColor,
|
||||||
|
size: code.size,
|
||||||
|
ecLevel: code.ecLevel,
|
||||||
|
margin: code.margin,
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = shortUrl(code.slug);
|
||||||
|
const body =
|
||||||
|
format === "png"
|
||||||
|
? new Uint8Array(await renderPng(content, design))
|
||||||
|
: await renderSvg(content, design);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": CONTENT_TYPES[format],
|
||||||
|
// Das Ziel eines dynamischen Codes ist änderbar; die Kurz-URL im Bild
|
||||||
|
// nicht. Trotzdem kein Caching, damit Gestaltungsänderungen sofort
|
||||||
|
// sichtbar werden.
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDownload) {
|
||||||
|
const module = getQrType(code.type);
|
||||||
|
const name = module
|
||||||
|
? downloadFilename(module, code.data, format)
|
||||||
|
: `qr-${code.slug}.${format}`;
|
||||||
|
headers["Content-Disposition"] = `attachment; filename="${name}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(body, { headers });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { designSchema } from "@/lib/qr/design";
|
||||||
|
import { getActiveQrType } from "@/lib/qr/registry";
|
||||||
|
import { QrRequestError } from "@/lib/qr/request";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Context = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
const updateSchema = z.object({
|
||||||
|
data: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
design: designSchema.optional(),
|
||||||
|
label: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadCode(id: string) {
|
||||||
|
const code = await prisma.qrCode.findUnique({ where: { id } });
|
||||||
|
if (!code) {
|
||||||
|
throw new QrRequestError("Dieser QR-Code existiert nicht (mehr).", 404);
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
context: Context,
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const { id } = await context.params;
|
||||||
|
return Response.json({ code: toQrCodeDto(await loadCode(id)) });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ändert Ziel, Name oder Gestaltung — die Kurz-URL bleibt bestehen. */
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
context: Context,
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const { id } = await context.params;
|
||||||
|
const parsed = updateSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await loadCode(id);
|
||||||
|
const update: Prisma.QrCodeUpdateInput = {};
|
||||||
|
|
||||||
|
if (parsed.data.data !== undefined) {
|
||||||
|
const module = getActiveQrType(existing.type);
|
||||||
|
if (!module) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Der Typ dieses Codes wird in dieser Version nicht unterstützt.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const validated = module.validate(parsed.data.data);
|
||||||
|
if (!validated.ok) {
|
||||||
|
return jsonError(validated.message);
|
||||||
|
}
|
||||||
|
update.data = toJsonInput(validated.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.label !== undefined) {
|
||||||
|
update.label =
|
||||||
|
parsed.data.label && parsed.data.label.length > 0
|
||||||
|
? parsed.data.label
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.design !== undefined) {
|
||||||
|
Object.assign(update, parsed.data.design);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(update).length === 0) {
|
||||||
|
return jsonError("Es wurden keine Änderungen übermittelt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.qrCode.update({ where: { id }, data: update });
|
||||||
|
return Response.json({ code: toQrCodeDto(updated) });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
context: Context,
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const { id } = await context.params;
|
||||||
|
await loadCode(id);
|
||||||
|
await prisma.qrCode.delete({ where: { id } });
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { toJsonInput, toQrCodeDto } from "@/lib/codes";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { designSchema } from "@/lib/qr/design";
|
||||||
|
import { getActiveQrType } from "@/lib/qr/registry";
|
||||||
|
import { QrRequestError } from "@/lib/qr/request";
|
||||||
|
import { QR_TYPE_IDS } from "@/lib/qr/type-module";
|
||||||
|
import { generateSlug } from "@/lib/slug";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
type: z.enum(QR_TYPE_IDS, { error: "Unbekannter QR-Typ." }),
|
||||||
|
data: z.record(z.string(), z.unknown()),
|
||||||
|
design: designSchema,
|
||||||
|
label: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." })
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === "object" &&
|
||||||
|
error !== null &&
|
||||||
|
"code" in error &&
|
||||||
|
(error as { code?: unknown }).code === "P2002"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle dynamischen Codes, neueste zuerst. */
|
||||||
|
export async function GET(): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const codes = await prisma.qrCode.findMany({ orderBy: { createdAt: "desc" } });
|
||||||
|
return Response.json({ codes: codes.map(toQrCodeDto) });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legt einen dynamischen Code an und vergibt dafür eine freie Kurz-URL. */
|
||||||
|
export async function POST(request: NextRequest): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const parsed = createSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, design, label } = parsed.data;
|
||||||
|
const module = getActiveQrType(type);
|
||||||
|
if (!module) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Dieser QR-Typ ist in dieser Version noch nicht verfügbar.",
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!module.supportsDynamic) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
`Der QR-Typ „${module.title}" unterstützt keine dynamischen Codes.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validated = module.validate(parsed.data.data);
|
||||||
|
if (!validated.ok) {
|
||||||
|
return jsonError(validated.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich.
|
||||||
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const created = await prisma.qrCode.create({
|
||||||
|
data: {
|
||||||
|
slug: generateSlug(),
|
||||||
|
type: module.id,
|
||||||
|
label: label && label.length > 0 ? label : null,
|
||||||
|
data: toJsonInput(validated.data),
|
||||||
|
...design,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return Response.json({ code: toQrCodeDto(created) }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
if (!isUniqueViolation(error)) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Es konnte keine freie Kurz-URL erzeugt werden. Bitte versuche es erneut.",
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render";
|
||||||
|
import {
|
||||||
|
downloadFilename,
|
||||||
|
qrRequestSchema,
|
||||||
|
QrRequestError,
|
||||||
|
resolveQrRequest,
|
||||||
|
} from "@/lib/qr/request";
|
||||||
|
|
||||||
|
const downloadSchema = qrRequestSchema.extend({
|
||||||
|
format: z.enum(["png", "svg"], { error: "Ungültiges Dateiformat." }),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Liefert den fertigen QR-Code als PNG- oder SVG-Datei zum Herunterladen. */
|
||||||
|
export async function POST(request: NextRequest): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const parsed = downloadSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { format, design } = parsed.data;
|
||||||
|
|
||||||
|
// Ein dynamischer Code ohne Slug wäre eine Weiterleitung ins Leere.
|
||||||
|
if (parsed.data.mode === "dynamic" && !parsed.data.slug) {
|
||||||
|
throw new QrRequestError(
|
||||||
|
"Bitte speichere den dynamischen Code zuerst — erst dann existiert die Kurz-URL.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { module, data, content } = resolveQrRequest(parsed.data);
|
||||||
|
|
||||||
|
const body =
|
||||||
|
format === "png"
|
||||||
|
? new Uint8Array(await renderPng(content, design))
|
||||||
|
: await renderSvg(content, design);
|
||||||
|
|
||||||
|
return new Response(body, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": CONTENT_TYPES[format],
|
||||||
|
"Content-Disposition": `attachment; filename="${downloadFilename(module, data, format)}"`,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
import { renderSvg } from "@/lib/qr/render";
|
||||||
|
import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die Live-Vorschau als SVG-Data-URL. Die Erzeugung passiert
|
||||||
|
* serverseitig, damit Vorschau und Download garantiert identisch sind.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const parsed = qrRequestSchema.safeParse(await request.json());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { content } = resolveQrRequest(parsed.data);
|
||||||
|
const svg = await renderSvg(content, parsed.data.design);
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
content,
|
||||||
|
dataUrl: `data:image/svg+xml;base64,${Buffer.from(svg, "utf8").toString("base64")}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import { QrGenerator } from "@/components/qr-generator";
|
||||||
|
import { QR_TYPE_IDS, getActiveQrType, getQrType } from "@/lib/qr/registry";
|
||||||
|
|
||||||
|
type Props = { params: Promise<{ type: string }> };
|
||||||
|
|
||||||
|
/** Alle bekannten Typen vorab erzeugen — auch die noch inaktiven. */
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return QR_TYPE_IDS.map((type) => ({ type }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { type } = await params;
|
||||||
|
const module = getQrType(type);
|
||||||
|
return { title: module ? `${module.title}-QR-Code` : "QR-Code erstellen" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CreatePage({ params }: Props) {
|
||||||
|
const { type } = await params;
|
||||||
|
const module = getQrType(type);
|
||||||
|
|
||||||
|
if (!module) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registrierte, aber noch nicht implementierte Typen bekommen eine
|
||||||
|
// erklärende Seite statt einer 404.
|
||||||
|
if (!getActiveQrType(type)) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl px-4 py-16 text-center sm:px-6">
|
||||||
|
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
|
||||||
|
<Icon name={module.icon} className="size-7" />
|
||||||
|
</span>
|
||||||
|
<h1 className="mt-5 text-2xl font-bold text-slate-900">
|
||||||
|
„{module.title}“ ist bald verfügbar
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-slate-600">
|
||||||
|
Dieser QR-Typ folgt in Version 2. In Version 1 steht der Typ „Website“
|
||||||
|
zur Verfügung.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/erstellen/website"
|
||||||
|
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
Website-QR-Code erstellen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <QrGenerator typeId={module.id} />;
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,43 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--font-sans:
|
||||||
|
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
|
||||||
|
Arial, "Noto Sans", sans-serif;
|
||||||
|
--font-mono:
|
||||||
|
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono",
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--color-slate-50);
|
||||||
|
color: var(--color-slate-900);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sichtbarer, einheitlicher Fokusring für Tastaturbedienung. */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-indigo-600);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Schachbrett hinter der Vorschau — macht helle Hintergrundfarben sichtbar. */
|
||||||
|
.qr-canvas {
|
||||||
|
background-color: #fff;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, #eef2f7 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #eef2f7 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #eef2f7 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #eef2f7 75%);
|
||||||
|
background-size: 16px 16px;
|
||||||
|
background-position:
|
||||||
|
0 0,
|
||||||
|
0 8px,
|
||||||
|
8px -8px,
|
||||||
|
-8px 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
default: "QR-Code-Generator",
|
||||||
|
template: "%s · QR-Code-Generator",
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
"QR-Codes erstellen: statisch oder dynamisch, mit eigenen Farben, frei wählbarer Größe und Download als PNG oder SVG.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
|
return (
|
||||||
|
<html lang="de" className="h-full">
|
||||||
|
<body className="flex min-h-full flex-col">
|
||||||
|
<header className="border-b border-slate-200 bg-white">
|
||||||
|
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3.5 sm:px-6">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-2.5 rounded-lg font-semibold text-slate-900"
|
||||||
|
>
|
||||||
|
<span className="flex size-9 items-center justify-center rounded-lg bg-indigo-600 text-white">
|
||||||
|
<Icon name="qr" className="size-5" />
|
||||||
|
</span>
|
||||||
|
QR-Code-Generator
|
||||||
|
</Link>
|
||||||
|
<nav className="flex items-center gap-1 text-sm font-medium">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
Erstellen
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/meine-codes"
|
||||||
|
className="rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
Meine Codes
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1">{children}</main>
|
||||||
|
|
||||||
|
<footer className="border-t border-slate-200 bg-white">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-6 text-sm text-slate-500 sm:px-6">
|
||||||
|
QR-Code-Generator · Version 1 — QR-Typ „Website/URL"
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { CodesList } from "@/components/codes-list";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Meine Codes" };
|
||||||
|
|
||||||
|
export default function MyCodesPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-10">
|
||||||
|
<div className="mb-7">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
||||||
|
Meine Codes
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-slate-600">
|
||||||
|
Alle dynamischen QR-Codes mit ihrem aktuellen Ziel und der Anzahl der
|
||||||
|
Scans. Statische Codes erscheinen hier nicht — sie werden nicht
|
||||||
|
gespeichert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CodesList />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-lg px-4 py-20 text-center sm:px-6">
|
||||||
|
<p className="text-5xl font-bold text-slate-300">404</p>
|
||||||
|
<h1 className="mt-4 text-2xl font-bold text-slate-900">
|
||||||
|
Seite nicht gefunden
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-slate-600">
|
||||||
|
Diese Seite gibt es nicht. Vielleicht hilft der Weg zurück zur
|
||||||
|
Typ-Auswahl.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
Zur Startseite
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { Icon } from "@/components/icons";
|
||||||
|
import { Badge } from "@/components/ui";
|
||||||
|
import { qrTypeList } from "@/lib/qr/registry";
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl">
|
||||||
|
Was möchtest du erstellen?
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 text-base text-slate-600">
|
||||||
|
Wähle einen Typ, gib deine Daten ein und lade den fertigen QR-Code als
|
||||||
|
PNG oder SVG herunter. Auf Wunsch als dynamischer Code, dessen Ziel du
|
||||||
|
später änderst, ohne den Code neu drucken zu müssen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="mt-9 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{qrTypeList.map((type) => {
|
||||||
|
const tile = (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
type.active
|
||||||
|
? "flex size-11 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600"
|
||||||
|
: "flex size-11 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-slate-400"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon name={type.icon} className="size-6" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
type.active
|
||||||
|
? "font-semibold text-slate-900"
|
||||||
|
: "font-semibold text-slate-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{type.title}
|
||||||
|
</span>
|
||||||
|
{type.active ? (
|
||||||
|
<Badge tone="popular">Beliebt</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge tone="soon">Bald verfügbar</Badge>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
type.active
|
||||||
|
? "mt-1 block text-sm text-slate-600"
|
||||||
|
: "mt-1 block text-sm text-slate-400"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{type.description}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={type.id}>
|
||||||
|
{type.active ? (
|
||||||
|
<Link
|
||||||
|
href={`/erstellen/${type.id}`}
|
||||||
|
className="flex h-full items-start gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm transition-all hover:border-indigo-300 hover:shadow-md"
|
||||||
|
>
|
||||||
|
{tile}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
aria-disabled="true"
|
||||||
|
title={`„${type.title}" folgt in Version 2`}
|
||||||
|
className="flex h-full cursor-not-allowed items-start gap-4 rounded-2xl border border-dashed border-slate-200 bg-slate-50/60 p-4"
|
||||||
|
>
|
||||||
|
{tile}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p className="mt-8 text-sm text-slate-500">
|
||||||
|
In Version 1 ist der Typ „Website" verfügbar. Die übrigen Typen folgen in
|
||||||
|
Version 2.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getQrType } from "@/lib/qr/registry";
|
||||||
|
import { isValidSlug } from "@/lib/slug";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Context = { params: Promise<{ slug: string }> };
|
||||||
|
|
||||||
|
function notFound(message: string): Response {
|
||||||
|
return new Response(
|
||||||
|
`<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>QR-Code nicht gefunden</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: system-ui, sans-serif; margin: 0; display: grid; place-items: center; min-height: 100vh; background: #f8fafc; color: #0f172a;">
|
||||||
|
<main style="text-align: center; padding: 2rem; max-width: 32rem;">
|
||||||
|
<p style="font-size: 3rem; margin: 0;">🔍</p>
|
||||||
|
<h1 style="font-size: 1.5rem; margin: 1rem 0 0.5rem;">QR-Code nicht gefunden</h1>
|
||||||
|
<p style="color: #475569; line-height: 1.6;">${message}</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
{ status: 404, headers: { "Content-Type": "text/html; charset=utf-8" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weiterleitung eines dynamischen QR-Codes.
|
||||||
|
*
|
||||||
|
* Bewusst 302 (temporär): Das Ziel ist jederzeit änderbar, ein permanenter
|
||||||
|
* Redirect würde in Browsern und Zwischenspeichern hängen bleiben.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
context: Context,
|
||||||
|
): Promise<Response> {
|
||||||
|
const { slug } = await context.params;
|
||||||
|
|
||||||
|
if (!isValidSlug(slug)) {
|
||||||
|
return notFound("Diese Kurz-Adresse ist ungültig.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = await prisma.qrCode.findUnique({ where: { slug } });
|
||||||
|
if (!code) {
|
||||||
|
return notFound(
|
||||||
|
"Der Code wurde gelöscht oder hat nie existiert. Bitte wende dich an denjenigen, der ihn erstellt hat.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const module = getQrType(code.type);
|
||||||
|
if (!module) {
|
||||||
|
return notFound("Der Typ dieses Codes wird nicht mehr unterstützt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let target: string;
|
||||||
|
try {
|
||||||
|
target = module.redirectTarget(code.data);
|
||||||
|
} catch {
|
||||||
|
return notFound("Das Ziel dieses Codes ist nicht mehr gültig.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Zähler darf die Weiterleitung nicht ausbremsen oder blockieren.
|
||||||
|
try {
|
||||||
|
await prisma.qrCode.update({
|
||||||
|
where: { id: code.id },
|
||||||
|
data: { scans: { increment: 1 }, lastScanAt: new Date() },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Scan-Zähler konnte nicht erhöht werden:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: {
|
||||||
|
Location: target,
|
||||||
|
// Ohne dies würden Browser die Weiterleitung zwischenspeichern und
|
||||||
|
// spätere Zieländerungen sowie der Scan-Zähler blieben wirkungslos.
|
||||||
|
"Cache-Control": "no-store, no-cache, must-revalidate",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user