init
This commit is contained in:
410
src/features/setup/components/setup-wizard.tsx
Normal file
410
src/features/setup/components/setup-wizard.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user