Files
jason 5847a175af
Build and Push Docker Image / build (push) Successful in 1m11s
stage 5-6
2026-04-21 13:14:27 -05:00

235 lines
6.7 KiB
TypeScript

"use client";
import { forwardRef, type ButtonHTMLAttributes, type InputHTMLAttributes, type SelectHTMLAttributes, type TextareaHTMLAttributes, type ReactNode } from "react";
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
// -------- Button ---------------------------------------------------------
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
type ButtonSize = "sm" | "md";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
}
const variantClasses: Record<ButtonVariant, string> = {
primary: "bg-slate-900 text-white hover:bg-slate-800 border-slate-900",
secondary: "bg-white text-slate-900 border-slate-300 hover:bg-slate-50",
danger: "bg-red-600 text-white border-red-600 hover:bg-red-500",
ghost: "bg-transparent text-slate-700 border-transparent hover:bg-slate-100",
};
const sizeClasses: Record<ButtonSize, string> = {
sm: "px-2.5 py-1.5 text-sm rounded-md",
md: "px-4 py-2 text-sm rounded-lg",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ variant = "primary", size = "md", className, ...props },
ref,
) {
return (
<button
ref={ref}
{...props}
className={cx(
"inline-flex items-center justify-center gap-1.5 border font-medium transition disabled:opacity-60 disabled:cursor-not-allowed",
variantClasses[variant],
sizeClasses[size],
className,
)}
/>
);
});
// -------- Field label wrapper --------------------------------------------
export function Field({
label,
hint,
error,
children,
required,
}: {
label: string;
hint?: ReactNode;
error?: string | null;
children: ReactNode;
required?: boolean;
}) {
return (
<label className="block">
<span className="block text-sm font-medium text-slate-700 mb-1">
{label}
{required ? <span className="text-red-600"> *</span> : null}
</span>
{children}
{hint && !error ? <span className="block text-xs text-slate-500 mt-1">{hint}</span> : null}
{error ? <span className="block text-xs text-red-600 mt-1">{error}</span> : null}
</label>
);
}
// -------- Input / Textarea / Select --------------------------------------
const inputBase =
"w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-slate-900 focus:ring-2 focus:ring-slate-200 disabled:bg-slate-50";
type InputProps = InputHTMLAttributes<HTMLInputElement>;
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ className, ...props },
ref,
) {
return <input ref={ref} {...props} className={cx(inputBase, className)} />;
});
type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
{ className, ...props },
ref,
) {
return (
<textarea
ref={ref}
{...props}
className={cx(inputBase, "min-h-[5rem] font-mono leading-relaxed", className)}
/>
);
});
type SelectProps = SelectHTMLAttributes<HTMLSelectElement>;
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
{ className, ...props },
ref,
) {
return <select ref={ref} {...props} className={cx(inputBase, "bg-white pr-8", className)} />;
});
// -------- Card / PageHeader / Table --------------------------------------
export function Card({ children, className }: { children: ReactNode; className?: string }) {
return (
<div className={cx("rounded-xl bg-white border border-slate-200 shadow-sm", className)}>
{children}
</div>
);
}
export function PageHeader({
title,
description,
actions,
}: {
title: ReactNode;
description?: ReactNode;
actions?: ReactNode;
}) {
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between mb-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description ? <p className="text-slate-500 mt-1">{description}</p> : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</div>
);
}
export function Empty({ title, description }: { title: string; description?: ReactNode }) {
return (
<div className="rounded-xl border border-dashed border-slate-300 bg-white p-10 text-center">
<p className="font-medium text-slate-700">{title}</p>
{description ? <p className="text-sm text-slate-500 mt-1">{description}</p> : null}
</div>
);
}
export function ErrorBanner({ message }: { message: string | null | undefined }) {
if (!message) return null;
return (
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2">
{message}
</p>
);
}
// -------- Modal ----------------------------------------------------------
export function Modal({
open,
onClose,
title,
children,
footer,
}: {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
footer?: ReactNode;
}) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/40">
<div
role="dialog"
aria-modal="true"
className="w-full max-w-lg rounded-2xl bg-white shadow-xl border border-slate-200"
>
<div className="flex items-center justify-between p-5 border-b border-slate-200">
<h2 className="font-semibold">{title}</h2>
<button
type="button"
onClick={onClose}
className="rounded-md p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-900"
aria-label="Close"
>
</button>
</div>
<div className="p-5 space-y-4 max-h-[70vh] overflow-y-auto">{children}</div>
{footer ? (
<div className="flex justify-end gap-2 p-4 border-t border-slate-200 bg-slate-50 rounded-b-2xl">
{footer}
</div>
) : null}
</div>
</div>
);
}
// -------- Badge ----------------------------------------------------------
export function Badge({
children,
tone = "slate",
className,
}: {
children: ReactNode;
tone?: "slate" | "green" | "amber" | "red" | "blue";
className?: string;
}) {
const tones: Record<string, string> = {
slate: "bg-slate-100 text-slate-700",
green: "bg-emerald-100 text-emerald-700",
amber: "bg-amber-100 text-amber-800",
red: "bg-red-100 text-red-700",
blue: "bg-blue-100 text-blue-700",
};
return (
<span
className={cx(
"inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium",
tones[tone],
className,
)}
>
{children}
</span>
);
}