feat: improve app ui and project metadata
This commit is contained in:
16
apps/web/components/ui/Badge.tsx
Normal file
16
apps/web/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
import { badgeClass, type BadgeVariant } from "@/lib/ui/variants";
|
||||
|
||||
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
children: ReactNode;
|
||||
variant?: BadgeVariant;
|
||||
}
|
||||
|
||||
export function Badge({ children, className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<span className={badgeClass({ variant, className })} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
36
apps/web/components/ui/Button.tsx
Normal file
36
apps/web/components/ui/Button.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
|
||||
|
||||
import {
|
||||
buttonClass,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
} from "@/lib/ui/variants";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
type = "button",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={buttonClass({ variant, size, className })}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
21
apps/web/components/ui/Card.tsx
Normal file
21
apps/web/components/ui/Card.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
import { cardClass } from "@/lib/ui/variants";
|
||||
|
||||
interface CardProps extends HTMLAttributes<HTMLElement> {
|
||||
children: ReactNode;
|
||||
as?: "article" | "div" | "section";
|
||||
}
|
||||
|
||||
export function Card({
|
||||
as: Component = "div",
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CardProps) {
|
||||
return (
|
||||
<Component className={cardClass(className)} {...props}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
38
apps/web/components/ui/EmptyState.tsx
Normal file
38
apps/web/components/ui/EmptyState.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/ui/variants";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded border border-dashed border-line bg-panel/70 px-6 py-10 text-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded bg-[var(--color-cinnabar-wash)] text-cinnabar">
|
||||
<Icon className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<h2 className="font-serif text-lg text-ink">{title}</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-ink-soft">
|
||||
{description}
|
||||
</p>
|
||||
{action ? <div className="mt-4">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
apps/web/components/ui/Field.tsx
Normal file
40
apps/web/components/ui/Field.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import {
|
||||
cn,
|
||||
fieldErrorClass,
|
||||
fieldHelpClass,
|
||||
fieldLabelClass,
|
||||
} from "@/lib/ui/variants";
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
htmlFor?: string;
|
||||
help?: ReactNode;
|
||||
error?: ReactNode;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
children,
|
||||
htmlFor,
|
||||
help,
|
||||
error,
|
||||
required = false,
|
||||
className,
|
||||
}: FieldProps) {
|
||||
const labelText = required ? `${label} *` : label;
|
||||
return (
|
||||
<div className={cn("space-y-1.5", className)}>
|
||||
<label htmlFor={htmlFor} className={fieldLabelClass()}>
|
||||
{labelText}
|
||||
</label>
|
||||
{children}
|
||||
{error ? <p className={fieldErrorClass()}>{error}</p> : null}
|
||||
{!error && help ? <p className={fieldHelpClass()}>{help}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
apps/web/components/ui/PageHeader.tsx
Normal file
38
apps/web/components/ui/PageHeader.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
eyebrow?: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
eyebrow,
|
||||
description,
|
||||
actions,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<header className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
{eyebrow ? (
|
||||
<p className="mb-1 font-mono text-xs uppercase tracking-wide text-ink-soft/70">
|
||||
{eyebrow}
|
||||
</p>
|
||||
) : null}
|
||||
<h1 className="font-serif text-2xl text-ink">{title}</h1>
|
||||
{description ? (
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-ink-soft">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
29
apps/web/components/ui/SectionHeader.tsx
Normal file
29
apps/web/components/ui/SectionHeader.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/ui/variants";
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: SectionHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex items-start justify-between gap-4", className)}>
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-serif text-base text-ink">{title}</h2>
|
||||
{description ? (
|
||||
<p className="mt-1 text-sm leading-6 text-ink-soft">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{action ? <div className="shrink-0">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
apps/web/components/ui/SegmentedControl.tsx
Normal file
48
apps/web/components/ui/SegmentedControl.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn, segmentedClass } from "@/lib/ui/variants";
|
||||
|
||||
export interface SegmentOption<T extends string> {
|
||||
value: T;
|
||||
label: ReactNode;
|
||||
}
|
||||
|
||||
interface SegmentedControlProps<T extends string> {
|
||||
options: Array<SegmentOption<T>>;
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
ariaLabel: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SegmentedControl<T extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
className,
|
||||
}: SegmentedControlProps<T>) {
|
||||
return (
|
||||
<div className={segmentedClass(className)} role="group" aria-label={ariaLabel}>
|
||||
{options.map((option) => {
|
||||
const selected = option.value === value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
"rounded px-3 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/35",
|
||||
selected
|
||||
? "bg-panel text-cinnabar shadow-paper"
|
||||
: "text-ink-soft hover:text-cinnabar",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
apps/web/components/ui/Select.tsx
Normal file
30
apps/web/components/ui/Select.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { SelectHTMLAttributes } from "react";
|
||||
|
||||
import { cn, inputClass, type InputState } from "@/lib/ui/variants";
|
||||
|
||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
controlSize?: "sm" | "md";
|
||||
state?: InputState;
|
||||
}
|
||||
|
||||
const selectSizes: Record<NonNullable<SelectProps["controlSize"]>, string> = {
|
||||
sm: "px-2 py-1 text-xs",
|
||||
md: "px-3 py-2 text-sm",
|
||||
};
|
||||
|
||||
export function Select({
|
||||
className,
|
||||
controlSize = "md",
|
||||
state,
|
||||
...props
|
||||
}: SelectProps) {
|
||||
return (
|
||||
<select
|
||||
className={inputClass({
|
||||
state,
|
||||
className: cn(selectSizes[controlSize], className),
|
||||
})}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
46
apps/web/components/ui/StatusNote.tsx
Normal file
46
apps/web/components/ui/StatusNote.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { AlertCircle, CheckCircle2, Info, TriangleAlert } from "lucide-react";
|
||||
|
||||
import {
|
||||
cn,
|
||||
statusNoteClass,
|
||||
type StatusNoteVariant,
|
||||
} from "@/lib/ui/variants";
|
||||
|
||||
interface StatusNoteProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
variant?: StatusNoteVariant;
|
||||
}
|
||||
|
||||
export function StatusNote({
|
||||
children,
|
||||
title,
|
||||
variant = "info",
|
||||
className,
|
||||
...props
|
||||
}: StatusNoteProps) {
|
||||
const Icon = iconForVariant(variant);
|
||||
return (
|
||||
<div className={statusNoteClass({ variant, className })} {...props}>
|
||||
<div className="flex gap-2">
|
||||
<Icon className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
{title ? (
|
||||
<p className="font-medium leading-5 text-ink">{title}</p>
|
||||
) : null}
|
||||
<div className={cn(title ? "mt-1" : "", "text-current")}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function iconForVariant(variant: StatusNoteVariant) {
|
||||
if (variant === "success") return CheckCircle2;
|
||||
if (variant === "warning") return TriangleAlert;
|
||||
if (variant === "danger") return AlertCircle;
|
||||
return Info;
|
||||
}
|
||||
30
apps/web/components/ui/TextArea.tsx
Normal file
30
apps/web/components/ui/TextArea.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { TextareaHTMLAttributes } from "react";
|
||||
|
||||
import { cn, inputClass, type InputState } from "@/lib/ui/variants";
|
||||
|
||||
interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
controlSize?: "sm" | "md";
|
||||
state?: InputState;
|
||||
}
|
||||
|
||||
const textAreaSizes: Record<NonNullable<TextAreaProps["controlSize"]>, string> = {
|
||||
sm: "px-2 py-1 text-xs leading-5",
|
||||
md: "px-3 py-2 text-sm leading-6",
|
||||
};
|
||||
|
||||
export function TextArea({
|
||||
className,
|
||||
controlSize = "md",
|
||||
state,
|
||||
...props
|
||||
}: TextAreaProps) {
|
||||
return (
|
||||
<textarea
|
||||
className={inputClass({
|
||||
state,
|
||||
className: cn("resize-y", textAreaSizes[controlSize], className),
|
||||
})}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
30
apps/web/components/ui/TextInput.tsx
Normal file
30
apps/web/components/ui/TextInput.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { InputHTMLAttributes } from "react";
|
||||
|
||||
import { cn, inputClass, type InputState } from "@/lib/ui/variants";
|
||||
|
||||
interface TextInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
controlSize?: "sm" | "md";
|
||||
state?: InputState;
|
||||
}
|
||||
|
||||
const inputSizes: Record<NonNullable<TextInputProps["controlSize"]>, string> = {
|
||||
sm: "px-2 py-1 text-xs",
|
||||
md: "px-3 py-2 text-sm",
|
||||
};
|
||||
|
||||
export function TextInput({
|
||||
className,
|
||||
controlSize = "md",
|
||||
state,
|
||||
...props
|
||||
}: TextInputProps) {
|
||||
return (
|
||||
<input
|
||||
className={inputClass({
|
||||
state,
|
||||
className: cn(inputSizes[controlSize], className),
|
||||
})}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user