160 lines
5.1 KiB
TypeScript
160 lines
5.1 KiB
TypeScript
|
|
import type { ComponentType, ReactNode } from 'react';
|
||
|
|
import { Inbox } from 'lucide-react';
|
||
|
|
import { cn } from '@/lib/utils';
|
||
|
|
import { BrandGlyph } from '@/components/brand/Brand';
|
||
|
|
import type { BrandGlyphName } from '@/lib/brand';
|
||
|
|
|
||
|
|
type Tone = 'neutral' | 'good' | 'warn' | 'danger' | 'info';
|
||
|
|
|
||
|
|
const toneClasses: Record<Tone, string> = {
|
||
|
|
neutral: 'border-border/75 bg-card/95 text-muted-foreground',
|
||
|
|
good: 'border-emerald-500/25 bg-emerald-500/[0.07] text-emerald-700 dark:text-emerald-300',
|
||
|
|
warn: 'border-amber-500/30 bg-amber-500/[0.08] text-amber-700 dark:text-amber-300',
|
||
|
|
danger: 'border-rose-500/30 bg-rose-500/[0.08] text-rose-700 dark:text-rose-300',
|
||
|
|
info: 'border-sky-500/25 bg-sky-500/[0.07] text-sky-700 dark:text-sky-300',
|
||
|
|
};
|
||
|
|
|
||
|
|
const dotClasses: Record<Tone, string> = {
|
||
|
|
neutral: 'bg-muted-foreground/55',
|
||
|
|
good: 'bg-emerald-500',
|
||
|
|
warn: 'bg-amber-500',
|
||
|
|
danger: 'bg-rose-500',
|
||
|
|
info: 'bg-sky-500',
|
||
|
|
};
|
||
|
|
|
||
|
|
interface PageHeaderProps {
|
||
|
|
eyebrow?: ReactNode;
|
||
|
|
title: ReactNode;
|
||
|
|
description?: ReactNode;
|
||
|
|
glyph?: BrandGlyphName;
|
||
|
|
icon?: ComponentType<{ className?: string }>;
|
||
|
|
actions?: ReactNode;
|
||
|
|
meta?: ReactNode;
|
||
|
|
className?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function PageHeader({
|
||
|
|
eyebrow,
|
||
|
|
title,
|
||
|
|
description,
|
||
|
|
glyph,
|
||
|
|
icon: Icon,
|
||
|
|
actions,
|
||
|
|
meta,
|
||
|
|
className,
|
||
|
|
}: PageHeaderProps) {
|
||
|
|
return (
|
||
|
|
<section className={cn('flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between', className)}>
|
||
|
|
<div className="flex min-w-0 items-start gap-3">
|
||
|
|
{glyph && <BrandGlyph name={glyph} className="mt-0.5 hidden sm:inline-grid" />}
|
||
|
|
{Icon && !glyph && (
|
||
|
|
<span className="mt-0.5 hidden h-10 w-10 shrink-0 place-items-center rounded-xl border border-primary/20 bg-primary/[0.08] text-primary shadow-sm shadow-primary/10 sm:grid">
|
||
|
|
<Icon className="h-5 w-5" />
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
<div className="min-w-0">
|
||
|
|
{eyebrow && (
|
||
|
|
<p className="mb-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||
|
|
{eyebrow}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
<h1 className="text-balance text-2xl font-semibold tracking-tight text-foreground sm:text-3xl">
|
||
|
|
{title}
|
||
|
|
</h1>
|
||
|
|
{description && (
|
||
|
|
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||
|
|
{description}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
{meta && <div className="mt-2 text-xs text-muted-foreground">{meta}</div>}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
{actions && <div className="flex flex-wrap items-center gap-2 sm:justify-end">{actions}</div>}
|
||
|
|
</section>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function SectionSurface({
|
||
|
|
children,
|
||
|
|
className,
|
||
|
|
tone = 'neutral',
|
||
|
|
}: {
|
||
|
|
children: ReactNode;
|
||
|
|
className?: string;
|
||
|
|
tone?: Tone;
|
||
|
|
}) {
|
||
|
|
return (
|
||
|
|
<section className={cn('surface-premium', tone !== 'neutral' && toneClasses[tone], className)}>
|
||
|
|
{children}
|
||
|
|
</section>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function StatusDot({ tone = 'neutral', pulse = false, className }: { tone?: Tone; pulse?: boolean; className?: string }) {
|
||
|
|
return (
|
||
|
|
<span className={cn('relative inline-flex h-2.5 w-2.5 shrink-0', className)} aria-hidden="true">
|
||
|
|
{pulse && <span className={cn('absolute inline-flex h-full w-full animate-ping rounded-full opacity-40', dotClasses[tone])} />}
|
||
|
|
<span className={cn('relative inline-flex h-2.5 w-2.5 rounded-full', dotClasses[tone])} />
|
||
|
|
</span>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function MetricCard({
|
||
|
|
label,
|
||
|
|
value,
|
||
|
|
hint,
|
||
|
|
icon: Icon,
|
||
|
|
tone = 'neutral',
|
||
|
|
action,
|
||
|
|
className,
|
||
|
|
}: {
|
||
|
|
label: ReactNode;
|
||
|
|
value: ReactNode;
|
||
|
|
hint?: ReactNode;
|
||
|
|
icon?: ComponentType<{ className?: string }>;
|
||
|
|
tone?: Tone;
|
||
|
|
action?: ReactNode;
|
||
|
|
className?: string;
|
||
|
|
}) {
|
||
|
|
return (
|
||
|
|
<div className={cn('metric-card', tone !== 'neutral' && toneClasses[tone], className)}>
|
||
|
|
<div className="flex items-start justify-between gap-3">
|
||
|
|
<div className="flex min-w-0 items-center gap-2">
|
||
|
|
{Icon && <Icon className="h-4 w-4 shrink-0 text-current opacity-80" />}
|
||
|
|
<p className="truncate text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||
|
|
{label}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
{action}
|
||
|
|
</div>
|
||
|
|
<p className="metric-value mt-3 text-2xl leading-none sm:text-[1.75rem]">{value}</p>
|
||
|
|
{hint && <p className="mt-2 text-xs leading-5 text-muted-foreground">{hint}</p>}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function EmptyState({
|
||
|
|
title,
|
||
|
|
description,
|
||
|
|
icon: Icon = Inbox,
|
||
|
|
action,
|
||
|
|
className,
|
||
|
|
}: {
|
||
|
|
title: ReactNode;
|
||
|
|
description?: ReactNode;
|
||
|
|
icon?: ComponentType<{ className?: string }>;
|
||
|
|
action?: ReactNode;
|
||
|
|
className?: string;
|
||
|
|
}) {
|
||
|
|
return (
|
||
|
|
<div className={cn('surface-premium flex flex-col items-center justify-center px-6 py-12 text-center', className)}>
|
||
|
|
<span className="grid h-11 w-11 place-items-center rounded-xl border border-border/70 bg-muted/35 text-muted-foreground">
|
||
|
|
<Icon className="h-5 w-5" />
|
||
|
|
</span>
|
||
|
|
<h2 className="mt-4 text-base font-semibold text-foreground">{title}</h2>
|
||
|
|
{description && <p className="mt-1 max-w-md text-sm leading-6 text-muted-foreground">{description}</p>}
|
||
|
|
{action && <div className="mt-4">{action}</div>}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|