web/src/components/ui.tsx
8.9 KB · sha256:2ca9cacde6cc6644a0729b048ffe1a1c1f5751feacc498801e27f6e31f42420a
import {
forwardRef,
type ButtonHTMLAttributes,
type InputHTMLAttributes,
type ReactNode,
type SelectHTMLAttributes,
type TableHTMLAttributes,
type TdHTMLAttributes,
type ThHTMLAttributes,
} from "react";
import { Loader2 } from "lucide-react";
import { cn } from "../lib/cn";
// ---- Logo ------------------------------------------------------------
export function Logo({
className,
size = 32,
withWordmark = false,
}: {
className?: string;
size?: number;
withWordmark?: boolean;
}) {
return (
<span className={cn("inline-flex items-center gap-2", className)}>
<img
src="/logo_transparent.png"
alt="Rune"
width={size}
height={size}
className="rounded-lg select-none"
draggable={false}
/>
{withWordmark && (
<span className="text-fg text-lg font-semibold tracking-tight">
Ward
</span>
)}
</span>
);
}
// ---- Button ----------------------------------------------------------
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "subtle";
type ButtonSize = "sm" | "md" | "lg" | "icon";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
/** Shows a spinner and disables interaction. Use with mutation.isPending. */
loading?: boolean;
}
const buttonBase =
"inline-flex items-center justify-center gap-2 rounded-lg font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface disabled:opacity-50 disabled:pointer-events-none whitespace-nowrap";
const buttonVariants: Record<ButtonVariant, string> = {
primary:
"bg-brand-600 text-white hover:bg-brand-700 shadow-sm shadow-brand-900/20",
secondary: "bg-surface text-fg border border-border hover:bg-surface-3",
ghost: "text-fg-muted hover:text-fg hover:bg-surface-3",
subtle:
"bg-brand-50 text-brand-700 hover:bg-brand-100 dark:bg-brand-900/30 dark:text-brand-200 dark:hover:bg-brand-900/50",
danger: "bg-red-500 text-white hover:bg-red-600",
};
const buttonSizes: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-11 px-5 text-base",
icon: "h-9 w-9",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
className,
variant = "secondary",
size = "md",
loading = false,
disabled,
children,
...props
},
ref,
) {
return (
<button
ref={ref}
disabled={disabled || loading}
aria-busy={loading || undefined}
className={cn(
buttonBase,
buttonVariants[variant],
buttonSizes[size],
className,
)}
{...props}
>
{loading && <Loader2 size={14} className="animate-spin" />}
{children}
</button>
);
},
);
// ---- Card ------------------------------------------------------------
export function Card({
className,
children,
}: {
className?: string;
children: ReactNode;
}) {
return (
<div
className={cn(
"bg-surface border border-border rounded-2xl shadow-sm",
className,
)}
>
{children}
</div>
);
}
export function CardHeader({
title,
description,
action,
className,
}: {
title: ReactNode;
description?: ReactNode;
action?: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"flex items-start justify-between gap-4 px-6 pt-6 pb-4",
className,
)}
>
<div className="min-w-0">
<h3 className="text-fg text-lg font-semibold tracking-tight">
{title}
</h3>
{description && (
<p className="text-fg-muted mt-0.5 text-sm">{description}</p>
)}
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
);
}
export function CardBody({
className,
children,
}: {
className?: string;
children: ReactNode;
}) {
return <div className={cn("px-6 pb-6", className)}>{children}</div>;
}
// ---- Input + Select --------------------------------------------------
export const Input = forwardRef<
HTMLInputElement,
InputHTMLAttributes<HTMLInputElement>
>(function Input({ className, ...props }, ref) {
return (
<input
ref={ref}
className={cn(
"bg-surface text-fg placeholder:text-fg-subtle border-border focus:border-brand-500 focus:ring-brand-500/20 h-10 w-full rounded-lg border px-3 text-sm transition outline-none focus:ring-4 disabled:opacity-50",
className,
)}
{...props}
/>
);
});
export const Select = forwardRef<
HTMLSelectElement,
SelectHTMLAttributes<HTMLSelectElement>
>(function Select({ className, children, ...props }, ref) {
return (
<select
ref={ref}
className={cn(
"bg-surface text-fg border-border focus:border-brand-500 focus:ring-brand-500/20 h-10 w-full rounded-lg border px-3 text-sm transition outline-none focus:ring-4 disabled:opacity-50",
className,
)}
{...props}
>
{children}
</select>
);
});
// ---- Badge -----------------------------------------------------------
type BadgeTone = "neutral" | "brand" | "success" | "warn" | "danger";
const badgeTones: Record<BadgeTone, string> = {
neutral: "bg-surface-3 text-fg-muted border-border",
brand:
"bg-brand-50 text-brand-700 border-brand-200 dark:bg-brand-900/30 dark:text-brand-200 dark:border-brand-700/50",
success:
"bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-200 dark:border-emerald-700/50",
warn: "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-200 dark:border-amber-700/50",
danger:
"bg-red-50 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-200 dark:border-red-700/50",
};
export function Badge({
tone = "neutral",
className,
children,
}: {
tone?: BadgeTone;
className?: string;
children: ReactNode;
}) {
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
badgeTones[tone],
className,
)}
>
{children}
</span>
);
}
// ---- Avatar (player head from mc-heads.net) --------------------------
export function PlayerAvatar({
uuid,
name,
size = 32,
className,
}: {
uuid?: string;
name?: string;
size?: number;
className?: string;
}) {
const target = uuid || name || "MHF_Steve";
const src = `https://mc-heads.net/avatar/${target}/${size * 2}`;
return (
<img
src={src}
width={size}
height={size}
alt={name || "player"}
className={cn(
"rounded-md bg-surface-3 select-none object-cover",
className,
)}
style={{ width: size, height: size, imageRendering: "pixelated" }}
draggable={false}
loading="lazy"
/>
);
}
// ---- Table -----------------------------------------------------------
export function Table({
className,
children,
...props
}: TableHTMLAttributes<HTMLTableElement>) {
return (
<div className="overflow-x-auto">
<table
className={cn("w-full border-collapse text-left", className)}
{...props}
>
{children}
</table>
</div>
);
}
export function Th({
className,
children,
...props
}: ThHTMLAttributes<HTMLTableCellElement>) {
return (
<th
className={cn(
"border-border text-fg-subtle border-b px-4 py-2.5 text-xs font-semibold tracking-wide uppercase",
className,
)}
{...props}
>
{children}
</th>
);
}
export function Td({
className,
children,
...props
}: TdHTMLAttributes<HTMLTableCellElement>) {
return (
<td
className={cn(
"border-border text-fg border-b px-4 py-3 text-sm",
className,
)}
{...props}
>
{children}
</td>
);
}
export function EmptyState({
title,
description,
action,
}: {
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-16 text-center">
<h4 className="text-fg text-base font-semibold">{title}</h4>
{description && (
<p className="text-fg-muted max-w-md text-sm">{description}</p>
)}
{action}
</div>
);
}
// ---- Section header --------------------------------------------------
export function PageHeader({
title,
description,
action,
}: {
title: ReactNode;
description?: ReactNode;
action?: ReactNode;
}) {
return (
<div className="mb-6 flex items-start justify-between gap-4">
<div className="min-w-0">
<h1 className="text-fg text-2xl font-semibold tracking-tight">
{title}
</h1>
{description && (
<p className="text-fg-muted mt-1 text-sm">{description}</p>
)}
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
);
}