542592972a
- Button: primary is now a struck-gilt gradient (from-accent-soft to-accent + inset highlight + accent-deep edge); add a `subtle` variant. - Input/Textarea/Select: gold focus (border-accent + 3px ring-accent-glow). - PageHeader: optional italic eyebrow + larger Spectral title + a gilt hairline rule. - Modal: rounded-xl + paper-grain panel. - New display primitives (Codex.tsx): Meter, Badge, Avatar, StatCoin — token-driven, ready for the screen ports. 223 unit + 34 e2e green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
67 lines
2.2 KiB
TypeScript
67 lines
2.2 KiB
TypeScript
import type { ReactNode } from 'react';
|
|
import { Link } from '@tanstack/react-router';
|
|
import { useActiveCampaign } from '@/features/campaigns/hooks';
|
|
import type { Campaign } from '@/lib/schemas';
|
|
|
|
export function Page({ children }: { children: ReactNode }) {
|
|
return <div className="mx-auto max-w-6xl px-4 py-6">{children}</div>;
|
|
}
|
|
|
|
export function PageHeader({
|
|
title,
|
|
subtitle,
|
|
eyebrow,
|
|
actions,
|
|
}: {
|
|
title: string;
|
|
subtitle?: string;
|
|
/** small italic accent line above the title (editorial eyebrow) */
|
|
eyebrow?: string;
|
|
actions?: ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="mb-6">
|
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
|
<div>
|
|
{eyebrow && <div className="font-display text-sm italic text-accent">{eyebrow}</div>}
|
|
<h1 className="font-display text-3xl font-semibold tracking-tight text-ink">{title}</h1>
|
|
{subtitle && <p className="mt-1 text-sm text-muted">{subtitle}</p>}
|
|
</div>
|
|
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
|
</div>
|
|
<hr className="gilt-rule mt-3" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function EmptyState({ title, hint, action }: { title: string; hint?: string; action?: ReactNode }) {
|
|
return (
|
|
<div className="rounded-lg border border-dashed border-line bg-panel/50 p-10 text-center">
|
|
<p className="font-medium text-ink">{title}</p>
|
|
{hint && <p className="mt-1 text-sm text-muted">{hint}</p>}
|
|
{action && <div className="mt-4 flex justify-center">{action}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Gate a screen on an active campaign; renders a prompt if none is selected. */
|
|
export function RequireCampaign({ children }: { children: (campaign: Campaign) => ReactNode }) {
|
|
const campaign = useActiveCampaign();
|
|
if (!campaign) {
|
|
return (
|
|
<Page>
|
|
<EmptyState
|
|
title="No campaign selected"
|
|
hint="Pick a campaign from the switcher up top, or create one to get started."
|
|
action={
|
|
<Link to="/" className="rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-ink">
|
|
Go to Campaigns
|
|
</Link>
|
|
}
|
|
/>
|
|
</Page>
|
|
);
|
|
}
|
|
return <>{children(campaign)}</>;
|
|
}
|