chore: reorganize frontend into app and admin roots
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
import type { ButtonHTMLAttributes, HTMLAttributes, InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from 'react';
|
||||
import { AlertCircle, CheckCircle2, Info, type LucideIcon } from 'lucide-react';
|
||||
import { cn } from '../lib/cn';
|
||||
|
||||
export function PageShell({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('flex-1 overflow-y-auto bg-stone-50 px-4 py-5 sm:p-6 lg:p-8', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function PageContainer({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('mx-auto max-w-7xl space-y-6 lg:space-y-8', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
className,
|
||||
}: {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between', className)}>
|
||||
<div>
|
||||
{eyebrow ? <p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">{eyebrow}</p> : null}
|
||||
<h1 className={cn(eyebrow ? 'mt-2' : '', 'text-3xl font-semibold tracking-tight text-stone-950')}>{title}</h1>
|
||||
{description ? <p className="mt-2 max-w-3xl text-sm leading-7 text-stone-600">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="w-full sm:w-auto sm:shrink-0">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('rounded-2xl border border-stone-200 bg-white shadow-sm', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function Surface({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('rounded-3xl border border-stone-200 bg-white shadow-sm', className)} {...props} />;
|
||||
}
|
||||
|
||||
const buttonVariants = {
|
||||
primary: 'bg-emerald-600 text-white shadow-sm hover:bg-emerald-700 focus-visible:ring-emerald-500',
|
||||
secondary: 'border border-stone-200 bg-white text-stone-700 hover:bg-stone-50 focus-visible:ring-emerald-500',
|
||||
subtle: 'bg-stone-100 text-stone-700 hover:bg-stone-200 focus-visible:ring-emerald-500',
|
||||
danger: 'bg-red-600 text-white shadow-sm hover:bg-red-700 focus-visible:ring-red-500',
|
||||
} as const;
|
||||
|
||||
const buttonSizes = {
|
||||
sm: 'h-10 px-4 text-sm',
|
||||
md: 'h-11 px-4 text-sm',
|
||||
lg: 'h-12 px-6 text-sm',
|
||||
icon: 'h-10 w-10',
|
||||
} as const;
|
||||
|
||||
export function Button({
|
||||
className,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: keyof typeof buttonVariants;
|
||||
size?: keyof typeof buttonSizes;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-2 rounded-xl font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
buttonVariants[variant],
|
||||
buttonSizes[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Input({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
'w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm text-stone-900 outline-none transition placeholder:text-stone-400 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Select({ className, ...props }: SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return (
|
||||
<select
|
||||
className={cn(
|
||||
'w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm text-stone-900 outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldLabel({ className, ...props }: HTMLAttributes<HTMLLabelElement>) {
|
||||
return <label className={cn('mb-2 block text-sm font-semibold text-stone-700', className)} {...props} />;
|
||||
}
|
||||
|
||||
const alertVariants = {
|
||||
error: {
|
||||
shell: 'border-red-100 bg-red-50 text-red-700',
|
||||
icon: AlertCircle,
|
||||
title: 'Issue',
|
||||
},
|
||||
success: {
|
||||
shell: 'border-emerald-200 bg-emerald-50/80 text-emerald-900',
|
||||
icon: CheckCircle2,
|
||||
title: 'Success',
|
||||
},
|
||||
info: {
|
||||
shell: 'border-stone-200 bg-stone-50 text-stone-700',
|
||||
icon: Info,
|
||||
title: 'Info',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function Alert({
|
||||
variant = 'info',
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
variant?: keyof typeof alertVariants;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const config = alertVariants[variant];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-start gap-3 rounded-2xl border p-4 text-sm', config.shell, className)}>
|
||||
<Icon className="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
{title ? <p className="font-semibold">{title}</p> : null}
|
||||
<div className={cn(title ? 'mt-1' : '')}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const badgeVariants = {
|
||||
neutral: 'border-stone-200 bg-stone-100 text-stone-700',
|
||||
primary: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
success: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
warning: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
danger: 'border-red-200 bg-red-50 text-red-700',
|
||||
info: 'border-sky-200 bg-sky-50 text-sky-700',
|
||||
} as const;
|
||||
|
||||
export function Badge({
|
||||
className,
|
||||
variant = 'neutral',
|
||||
icon: Icon,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
variant?: keyof typeof badgeVariants;
|
||||
icon?: LucideIcon;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span className={cn('inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-semibold', badgeVariants[variant], className)}>
|
||||
{Icon ? <Icon className="h-3.5 w-3.5" /> : null}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricPill({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-medium text-stone-600 shadow-sm', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: {
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className={cn('border-dashed p-6 text-center sm:p-10', className)}>
|
||||
{Icon ? (
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-stone-100 text-stone-500">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
) : null}
|
||||
<p className={cn('text-lg font-semibold text-stone-900', Icon ? 'mt-4' : '')}>{title}</p>
|
||||
<p className="mt-2 text-sm text-stone-500">{description}</p>
|
||||
{action ? <div className="mt-5">{action}</div> : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingState({ message }: { message: string }) {
|
||||
return (
|
||||
<Card className="flex items-center gap-3 p-5 text-sm text-stone-500">
|
||||
<svg className="h-4 w-4 animate-spin text-emerald-600" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" className="opacity-20" stroke="currentColor" strokeWidth="4" />
|
||||
<path d="M22 12a10 10 0 0 0-10-10" className="opacity-100" stroke="currentColor" strokeWidth="4" />
|
||||
</svg>
|
||||
{message}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function SegmentedTabs<T extends string>({
|
||||
tabs,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
tabs: Array<{ value: T; label: string; icon?: LucideIcon }>;
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="sticky top-0 z-20 -mx-1 bg-stone-50/95 px-1 pb-2 pt-1 backdrop-blur-sm sm:-mx-2 sm:px-2">
|
||||
<Surface className="p-2">
|
||||
<div className={cn('grid gap-2', tabs.length === 2 ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1 sm:grid-cols-3')}>
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = tab.value === value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
onClick={() => onChange(tab.value)}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 rounded-2xl px-4 py-3 text-sm font-semibold transition',
|
||||
isActive ? 'bg-stone-900 text-white shadow-sm' : 'text-stone-600 hover:bg-stone-50 hover:text-stone-900',
|
||||
)}
|
||||
>
|
||||
{Icon ? <Icon className="h-4 w-4" /> : null}
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Surface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
}: {
|
||||
title: string;
|
||||
value: ReactNode;
|
||||
icon: LucideIcon;
|
||||
}) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="rounded-xl bg-stone-100 p-3 text-emerald-700">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-500">{title}</p>
|
||||
<p className="text-2xl font-semibold tracking-tight text-stone-950">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user