Files
alla-allaos-fullstack/src/features/setup/components/setup-wizard.tsx
phaichayon 7a390cf0df init
2026-06-11 12:46:57 +07:00

411 lines
17 KiB
TypeScript

'use client';
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { Icons } from '@/components/icons';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
type WizardState = {
appUrl: string;
authSecret: string;
dbHost: string;
dbPort: string;
dbName: string;
dbUser: string;
dbPassword: string;
superAdminEmail: string;
superAdminPassword: string;
superAdminName: string;
resetPassword: boolean;
buildStandalone: boolean;
};
const steps = [
'Project URL',
'Database',
'Auth + seed',
'Preview .env.local'
] as const;
function generateSecret() {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('');
}
function download(filename: string, content: string) {
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
export function SetupWizard() {
const [copied, setCopied] = useState(false);
const [state, setState] = useState<WizardState>({
appUrl: 'http://localhost:3000',
authSecret: '',
dbHost: 'localhost',
dbPort: '5432',
dbName: 'training_system',
dbUser: 'postgres',
dbPassword: 'postgres',
superAdminEmail: 'admin@example.com',
superAdminPassword: 'change-this-password',
superAdminName: 'Super Admin',
resetPassword: false,
buildStandalone: false
});
useEffect(() => {
setState((current) =>
current.authSecret ? current : { ...current, authSecret: generateSecret() }
);
}, []);
const databaseUrl = useMemo(() => {
const user = encodeURIComponent(state.dbUser);
const password = encodeURIComponent(state.dbPassword);
return `postgres://${user}:${password}@${state.dbHost}:${state.dbPort}/${state.dbName}`;
}, [state.dbHost, state.dbName, state.dbPassword, state.dbPort, state.dbUser]);
const envContent = useMemo(
() =>
[
'# Generated by /setup',
'',
'# Authentication (Auth.js)',
`AUTH_SECRET=${state.authSecret}`,
'',
'# Optional local app URL reference',
`APP_URL=${state.appUrl}`,
'',
'# Database (PostgreSQL)',
`DATABASE_URL=${databaseUrl}`,
'',
'# Initial Super Admin Seed',
`SUPER_ADMIN_EMAIL=${state.superAdminEmail}`,
`SUPER_ADMIN_PASSWORD=${state.superAdminPassword}`,
`SUPER_ADMIN_NAME=${state.superAdminName}`,
`SUPER_ADMIN_RESET_PASSWORD=${state.resetPassword ? 'true' : 'false'}`,
'',
'# Build Configuration',
`BUILD_STANDALONE=${state.buildStandalone ? 'true' : ''}`,
'',
'# Sentry (optional)',
'NEXT_PUBLIC_SENTRY_DSN=',
'NEXT_PUBLIC_SENTRY_ORG=',
'NEXT_PUBLIC_SENTRY_PROJECT=',
'SENTRY_AUTH_TOKEN=',
'NEXT_PUBLIC_SENTRY_DISABLED="false"'
].join('\n'),
[databaseUrl, state]
);
async function copyToClipboard() {
await navigator.clipboard.writeText(envContent);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
}
const completedSteps = [
!!state.appUrl.trim(),
!!state.dbHost.trim() && !!state.dbPort.trim() && !!state.dbName.trim(),
!!state.authSecret.trim() && !!state.superAdminEmail.trim() && !!state.superAdminPassword.trim(),
true
].filter(Boolean).length;
return (
<div className='min-h-screen bg-[linear-gradient(135deg,_hsl(var(--background))_20%,_hsl(var(--muted)/0.35)),radial-gradient(circle_at_top_right,_hsl(var(--primary)/0.16),_transparent_32%)]'>
<div className='mx-auto max-w-7xl px-4 py-10 md:px-6'>
<div className='grid gap-6 xl:grid-cols-[1.1fr_0.9fr]'>
<div className='space-y-6'>
<div className='space-y-4'>
<Badge variant='outline' className='rounded-full px-3 py-1'>
Template onboarding
</Badge>
<div className='space-y-3'>
<h1 className='max-w-3xl text-4xl font-semibold tracking-tight md:text-6xl'>
Configure the template, then jump straight into the example dashboard.
</h1>
<p className='max-w-2xl text-base leading-7 text-muted-foreground md:text-lg'>
This setup wizard collects your local database and Auth.js seed values, generates
a ready-to-use <code>.env.local</code>, and points you to the public example
routes under <code>/example/dashboard/*</code>.
</p>
</div>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<Card className='rounded-3xl border-border/60 bg-background/75 backdrop-blur-sm'>
<CardHeader>
<CardTitle className='flex items-center gap-2'>
<Icons.settings className='h-5 w-5' />
Local app
</CardTitle>
<CardDescription>Reference URL used during local development.</CardDescription>
</CardHeader>
<CardContent className='space-y-2'>
<Label htmlFor='appUrl'>App URL</Label>
<Input
id='appUrl'
value={state.appUrl}
onChange={(event) => setState({ ...state, appUrl: event.target.value })}
placeholder='http://localhost:3000'
/>
</CardContent>
</Card>
<Card className='rounded-3xl border-border/60 bg-background/75 backdrop-blur-sm'>
<CardHeader>
<CardTitle className='flex items-center gap-2'>
<Icons.lock className='h-5 w-5' />
Auth secret
</CardTitle>
<CardDescription>Generated automatically for Auth.js credentials flow.</CardDescription>
</CardHeader>
<CardContent className='space-y-3'>
<Input
value={state.authSecret}
onChange={(event) => setState({ ...state, authSecret: event.target.value })}
/>
<Button
type='button'
variant='outline'
className='rounded-2xl'
onClick={() => setState({ ...state, authSecret: generateSecret() })}
>
<Icons.sparkles className='mr-2 h-4 w-4' />
Regenerate secret
</Button>
</CardContent>
</Card>
</div>
<Card className='rounded-[28px] border-border/60 bg-background/80 shadow-xl backdrop-blur-sm'>
<CardHeader>
<CardTitle>Database configuration</CardTitle>
<CardDescription>
Fill in the PostgreSQL connection details. The wizard will build
<code> DATABASE_URL</code> for you.
</CardDescription>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
<div className='space-y-2'>
<Label htmlFor='dbHost'>Host</Label>
<Input
id='dbHost'
value={state.dbHost}
onChange={(event) => setState({ ...state, dbHost: event.target.value })}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='dbPort'>Port</Label>
<Input
id='dbPort'
value={state.dbPort}
onChange={(event) => setState({ ...state, dbPort: event.target.value })}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='dbName'>Database name</Label>
<Input
id='dbName'
value={state.dbName}
onChange={(event) => setState({ ...state, dbName: event.target.value })}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='dbUser'>Database user</Label>
<Input
id='dbUser'
value={state.dbUser}
onChange={(event) => setState({ ...state, dbUser: event.target.value })}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='dbPassword'>Database password</Label>
<Input
id='dbPassword'
type='password'
value={state.dbPassword}
onChange={(event) => setState({ ...state, dbPassword: event.target.value })}
/>
</div>
<div className='space-y-2 md:col-span-2 xl:col-span-1'>
<Label htmlFor='databaseUrlPreview'>DATABASE_URL preview</Label>
<Input id='databaseUrlPreview' value={databaseUrl} readOnly />
</div>
</CardContent>
</Card>
<Card className='rounded-[28px] border-border/60 bg-background/80 shadow-xl backdrop-blur-sm'>
<CardHeader>
<CardTitle>Initial super admin seed</CardTitle>
<CardDescription>
These values feed the seed script used by <code>npm run setup:db</code>.
</CardDescription>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='superAdminEmail'>Email</Label>
<Input
id='superAdminEmail'
value={state.superAdminEmail}
onChange={(event) =>
setState({ ...state, superAdminEmail: event.target.value })
}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='superAdminPassword'>Password</Label>
<Input
id='superAdminPassword'
type='password'
value={state.superAdminPassword}
onChange={(event) =>
setState({ ...state, superAdminPassword: event.target.value })
}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='superAdminName'>Display name</Label>
<Input
id='superAdminName'
value={state.superAdminName}
onChange={(event) =>
setState({ ...state, superAdminName: event.target.value })
}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='resetPassword'>Seed behavior</Label>
<div className='flex h-10 items-center rounded-xl border px-3 text-sm text-muted-foreground'>
<input
id='resetPassword'
type='checkbox'
aria-label='Reset the seeded super admin password on reruns'
checked={state.resetPassword}
onChange={(event) =>
setState({ ...state, resetPassword: event.target.checked })
}
className='mr-3'
/>
Reset the seeded super admin password on reruns
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='buildStandalone'>Build mode</Label>
<div className='flex h-10 items-center rounded-xl border px-3 text-sm text-muted-foreground'>
<input
id='buildStandalone'
type='checkbox'
aria-label='Enable standalone build output'
checked={state.buildStandalone}
onChange={(event) =>
setState({ ...state, buildStandalone: event.target.checked })
}
className='mr-3'
/>
Enable standalone output for Docker-oriented builds
</div>
</div>
</CardContent>
</Card>
</div>
<div className='space-y-6'>
<Card className='rounded-[28px] border-border/60 bg-background/85 shadow-xl backdrop-blur-sm'>
<CardHeader>
<CardTitle className='flex items-center justify-between gap-4'>
<span>.env.local preview</span>
<Badge variant='secondary'>{completedSteps}/{steps.length} steps ready</Badge>
</CardTitle>
<CardDescription>
Copy or download this file, then run <code>npm run setup:db</code>.
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<Textarea
value={envContent}
readOnly
className='min-h-[420px] rounded-2xl font-mono text-xs'
/>
<div className='flex flex-wrap gap-3'>
<Button type='button' className='rounded-2xl' onClick={copyToClipboard}>
<Icons.check className='mr-2 h-4 w-4' />
{copied ? 'Copied' : 'Copy .env.local'}
</Button>
<Button
type='button'
variant='outline'
className='rounded-2xl'
onClick={() => download('.env.local', envContent)}
>
<Icons.upload className='mr-2 h-4 w-4' />
Download file
</Button>
<Button asChild variant='secondary' className='rounded-2xl'>
<Link href='/example/dashboard/overview'>
<Icons.arrowRight className='mr-2 h-4 w-4' />
Open example dashboard
</Link>
</Button>
</div>
</CardContent>
</Card>
<Card className='rounded-[28px] border-border/60 bg-background/75 backdrop-blur-sm'>
<CardHeader>
<CardTitle>Next steps</CardTitle>
<CardDescription>Suggested flow after generating the env file.</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{steps.map((step, index) => (
<div key={step} className='flex items-start gap-3'>
<div
className={cn(
'mt-0.5 flex h-7 w-7 items-center justify-center rounded-full border text-xs font-semibold',
index < completedSteps
? 'bg-primary text-primary-foreground border-primary'
: 'text-muted-foreground'
)}
>
{index + 1}
</div>
<div>
<p className='font-medium'>{step}</p>
<p className='text-sm text-muted-foreground'>
{index === 0 && 'Confirm the URL you will use locally while iterating.'}
{index === 1 && 'Provide PostgreSQL connection details for local development.'}
{index === 2 &&
'Set credentials and seed values for the first super admin account.'}
{index === 3 &&
'Copy the generated file, run the seed command, then inspect /example/dashboard/*.'}
</p>
</div>
</div>
))}
<div className='rounded-2xl border border-dashed p-4 text-sm text-muted-foreground'>
After saving <code>.env.local</code>, run <code>npm run setup:db</code>, then
<code> npm run dev</code>. The app will continue to open on <code>/setup</code>,
while all public showcase pages live under <code>/example/dashboard/*</code>.
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
}