Files
inbuxa-admin/src/components/wizard/WizardShell.tsx
T
jcoffey-dev 5641560a91 Guided wizards, opt-in every time, and automatic DNS as the first
A job that has a wizard now asks "Guide me / I'll do it myself" each time
it starts; nothing is remembered. The shared wizard shell gives every guide
a stepper, a side panel on what each step does and how to undo it, and the
way forward or back.

Automatic DNS, from a domain's DNS section:
- finds where the domain's DNS is hosted from its SOA and NS records, and
  offers that host when the server can drive it;
- for the major hosts, steps to create the narrowest credential, and the
  field named as the steps name it;
- records grouped by what they do, TLSA off unless the zone is signed;
- saves the provider and switches the domain over, removing the provider
  again if the switch fails;
- watches the publishing task and public DNS, ticking each record green,
  and boils a host's refusal down to its distinct messages;
- for hosts it can't drive, or domains not in DNS yet, every record laid
  out for copying, with the same live checks.
2026-09-19 01:41:00 -07:00

149 lines
4.3 KiB
TypeScript

/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, ArrowRight, Check, Loader2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { PageHeader } from '@/components/common/PageHeader';
import { cn } from '@/lib/utils';
export interface WizardStep {
id: string;
title: string;
}
/**
* The frame every guided job shares: where you are in it, the step itself,
* a side panel saying what this step does (and how to undo it, for the big
* jobs), and the way forward or back. The steps own their content and decide
* when "Next" is allowed; the shell never does anything on its own.
*/
export function WizardShell({
icon,
title,
subtitle,
steps,
current,
children,
aside,
canNext = true,
busy = false,
nextLabel,
onBack,
onNext,
onCancel,
hideFooter = false,
}: {
icon: string;
title: ReactNode;
subtitle?: ReactNode;
steps: WizardStep[];
current: number;
children: ReactNode;
aside?: ReactNode;
canNext?: boolean;
busy?: boolean;
nextLabel?: string;
onBack?: () => void;
onNext?: () => void;
onCancel: () => void;
hideFooter?: boolean;
}) {
const { t } = useTranslation();
return (
<div className="mx-auto max-w-5xl space-y-6">
<PageHeader
icon={icon}
title={title}
subtitle={subtitle}
actions={
<Button variant="ghost" onClick={onCancel}>
<X className="h-4 w-4" />
{t('wizard.close', 'Close')}
</Button>
}
/>
<ol className="flex flex-wrap items-center gap-x-2 gap-y-3" aria-label={t('wizard.progress', 'Progress')}>
{steps.map((s, i) => {
const done = i < current;
const here = i === current;
return (
<li key={s.id} className="flex items-center gap-2" aria-current={here ? 'step' : undefined}>
<span
className={cn(
'flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold transition-colors',
done && 'bg-primary text-primary-foreground',
here && 'bg-primary/15 text-primary ring-2 ring-primary',
!done && !here && 'bg-muted text-muted-foreground',
)}
>
{done ? <Check className="h-3.5 w-3.5" /> : i + 1}
</span>
<span className={cn('text-sm', here ? 'font-medium text-foreground' : 'text-muted-foreground')}>
{s.title}
</span>
{i < steps.length - 1 && <span className="mx-1 hidden h-px w-8 bg-border sm:block" aria-hidden />}
</li>
);
})}
</ol>
<div className={cn('grid items-start gap-6', aside && 'lg:grid-cols-[minmax(0,1fr)_18rem]')}>
<Card>
<CardContent className="space-y-6 pt-6">{children}</CardContent>
</Card>
{aside && <aside className="space-y-4 text-sm">{aside}</aside>}
</div>
{!hideFooter && (
<div className="flex items-center justify-between">
{onBack ? (
<Button variant="ghost" onClick={onBack} disabled={busy}>
<ArrowLeft className="h-4 w-4" />
{t('wizard.back', 'Back')}
</Button>
) : (
<span />
)}
{onNext && (
<Button onClick={onNext} disabled={!canNext || busy}>
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
{nextLabel ?? t('wizard.next', 'Next')}
{!busy && <ArrowRight className="h-4 w-4" />}
</Button>
)}
</div>
)}
</div>
);
}
/** A side-panel note: what this step does, or how to undo it. */
export function WizardNote({
title,
children,
tone = 'plain',
}: {
title: string;
children: ReactNode;
tone?: 'plain' | 'undo';
}) {
return (
<div
className={cn(
'rounded-xl border p-4',
tone === 'undo' ? 'border-emerald-500/30 bg-emerald-500/5' : 'border-border bg-card',
)}
>
<p className="mb-1.5 font-medium">{title}</p>
<div className="space-y-2 text-muted-foreground">{children}</div>
</div>
);
}