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,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