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
+94
View File
@@ -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>
);
}