V1: Next.js-App mit QR-Typ-Registry, Website-Generator und dynamischen Codes

- Erweiterbare Registry aller 15 QR-Typen; nur "Website" ist aktiv, die
  uebrigen erscheinen als Platzhalter mit Badge "Bald verfuegbar".
- defineQrType() kapselt Zod-Schema, Kodierung und Redirect-Ziel je Typ,
  sodass ein V2-Typ nur aus einem Registry-Eintrag plus Formular besteht.
- Serverseitige QR-Erzeugung (PNG/SVG) mit Farben, Groesse, Fehlerkorrektur
  und Rand; Live-Vorschau ueber /api/qr/preview.
- Dynamische Codes: Kurz-URL {BASE_URL}/r/{slug}, 302-Redirect mit
  Scan-Zaehler, CRUD unter /api/codes.
- Prisma 7 mit pg-Driver-Adapter und Initial-Migration fuer PostgreSQL.
- Deutsche Oberflaeche mit Tailwind CSS, helles Theme, responsiv.
This commit is contained in:
Florian Wilde
2026-08-13 15:54:47 +02:00
commit ad4cd92f8e
52 changed files with 12348 additions and 0 deletions
+213
View File
@@ -0,0 +1,213 @@
"use client";
import { Icon } from "@/components/icons";
import { Field, Notice, TextInput, cx } from "@/components/ui";
import { evaluateContrast } from "@/lib/qr/contrast";
import {
COLOR_PRESETS,
EC_LEVELS,
EC_LEVEL_LABELS,
MAX_SIZE,
MIN_SIZE,
type EcLevel,
type QrDesign,
} from "@/lib/qr/design";
const HEX_INPUT = /^#[0-9a-fA-F]{6}$/;
function ColorField({
label,
value,
onChange,
id,
}: {
label: string;
value: string;
onChange: (value: string) => void;
id: string;
}) {
const valid = HEX_INPUT.test(value);
return (
<Field
label={label}
htmlFor={id}
error={valid ? null : "Bitte im Format #RRGGBB angeben."}
>
<div className="flex items-center gap-2">
<input
type="color"
aria-label={`${label} auswählen`}
value={valid ? value : "#000000"}
onChange={(event) => onChange(event.target.value.toUpperCase())}
className="size-10 shrink-0 cursor-pointer rounded-lg border border-slate-300 bg-white p-1"
/>
<TextInput
id={id}
value={value}
invalid={!valid}
spellCheck={false}
maxLength={7}
onChange={(event) => {
const next = event.target.value.trim();
onChange(next.startsWith("#") ? next.toUpperCase() : `#${next.toUpperCase()}`);
}}
className="font-mono uppercase"
/>
</div>
</Field>
);
}
export function DesignPanel({
design,
onChange,
}: {
design: QrDesign;
onChange: (design: QrDesign) => void;
}) {
const patch = (changes: Partial<QrDesign>) => onChange({ ...design, ...changes });
const contrast = evaluateContrast(design.fgColor, design.bgColor);
return (
<div className="space-y-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<ColorField
id="fg-color"
label="Vordergrund"
value={design.fgColor}
onChange={(fgColor) => patch({ fgColor })}
/>
<ColorField
id="bg-color"
label="Hintergrund"
value={design.bgColor}
onChange={(bgColor) => patch({ bgColor })}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
{COLOR_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
title={preset.label}
onClick={() =>
patch({ fgColor: preset.fgColor, bgColor: preset.bgColor })
}
className="flex items-center gap-2 rounded-lg border border-slate-300 bg-white py-1 pr-2.5 pl-1.5 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
<span
className="size-4 rounded border border-slate-300"
style={{
background: `linear-gradient(135deg, ${preset.fgColor} 50%, ${preset.bgColor} 50%)`,
}}
/>
{preset.label}
</button>
))}
<button
type="button"
onClick={() =>
patch({ fgColor: design.bgColor, bgColor: design.fgColor })
}
className="rounded-lg border border-slate-300 bg-white px-2.5 py-1 text-xs font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
Farben tauschen
</button>
</div>
{contrast.message ? (
<Notice tone={contrast.verdict === "schlecht" ? "error" : "warning"}>
<span className="flex gap-2">
<Icon name="warning" className="mt-0.5 size-4 shrink-0" />
<span>
{contrast.message}
<span className="mt-0.5 block text-xs opacity-80">
Kontrastverhältnis: {contrast.ratio.toFixed(1)} : 1
</span>
</span>
</span>
</Notice>
) : null}
<Field
label={`Größe: ${design.size} × ${design.size} px`}
htmlFor="qr-size"
hint={`Zwischen ${MIN_SIZE} und ${MAX_SIZE} px. Gilt für den PNG-Download; das SVG skaliert verlustfrei.`}
>
<div className="flex items-center gap-3">
<input
id="qr-size"
type="range"
min={MIN_SIZE}
max={MAX_SIZE}
step={32}
value={design.size}
onChange={(event) => patch({ size: Number(event.target.value) })}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600"
/>
<input
type="number"
aria-label="Größe in Pixeln"
min={MIN_SIZE}
max={MAX_SIZE}
value={design.size}
onChange={(event) => {
const raw = Number(event.target.value);
if (Number.isNaN(raw)) return;
patch({ size: Math.min(MAX_SIZE, Math.max(MIN_SIZE, Math.round(raw))) });
}}
className="w-24 shrink-0 rounded-lg border border-slate-300 bg-white px-2.5 py-2 text-sm tabular-nums shadow-sm"
/>
</div>
</Field>
<Field
label="Fehlerkorrektur"
hint={EC_LEVEL_LABELS[design.ecLevel]}
>
<div
role="radiogroup"
aria-label="Fehlerkorrektur-Level"
className="grid grid-cols-4 gap-2"
>
{EC_LEVELS.map((level: EcLevel) => (
<button
key={level}
type="button"
role="radio"
aria-checked={design.ecLevel === level}
onClick={() => patch({ ecLevel: level })}
className={cx(
"rounded-lg border py-2 text-sm font-medium transition-colors",
design.ecLevel === level
? "border-indigo-600 bg-indigo-50 text-indigo-700"
: "border-slate-300 bg-white text-slate-600 hover:bg-slate-50",
)}
>
{level}
</button>
))}
</div>
</Field>
<Field
label={`Rand: ${design.margin} Module`}
htmlFor="qr-margin"
hint="Die weiße Ruhezone rundherum. Weniger als 4 Module können das Scannen erschweren."
>
<input
id="qr-margin"
type="range"
min={0}
max={16}
step={1}
value={design.margin}
onChange={(event) => patch({ margin: Number(event.target.value) })}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600"
/>
</Field>
</div>
);
}