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