- 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.
183 lines
4.4 KiB
TypeScript
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>
|
|
);
|
|
}
|