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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user