Files
qrcode-generator/src/components/ui.tsx
T
Florian Wilde ad4cd92f8e 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.
2026-08-13 15:54:47 +02:00

183 lines
4.4 KiB
TypeScript

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>
);
}