mkstack/src/components/auth/SignupDialog.tsx

377 lines
12 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import { Download, Key, Copy, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog.tsx';
import { toast } from '@/hooks/useToast';
import { useLoginActions } from '@/hooks/useLoginActions';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useUploadFile } from '@/hooks/useUploadFile';
import { generateSecretKey, nip19 } from 'nostr-tools';
interface SignupDialogProps {
isOpen: boolean;
onClose: () => void;
}
const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
const [step, setStep] = useState<'generate' | 'download' | 'profile' | 'done'>('generate');
const [isLoading, setIsLoading] = useState(false);
const [nsec, setNsec] = useState('');
const [keySecured, setKeySecured] = useState<'none' | 'copied' | 'downloaded'>('none');
const [profileData, setProfileData] = useState({
name: '',
about: '',
picture: ''
});
const login = useLoginActions();
const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const avatarFileInputRef = useRef<HTMLInputElement>(null);
const generateKey = () => {
setIsLoading(true);
setTimeout(() => {
try {
const sk = generateSecretKey();
setNsec(nip19.nsecEncode(sk));
setStep('download');
toast({
title: 'Key Generated',
description: 'Your new key is ready.',
});
} catch {
toast({
title: 'Error',
description: 'Failed to generate key. Please try again.',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
}, 500);
};
const downloadKey = () => {
try {
const blob = new Blob([nsec], { type: 'text/plain; charset=utf-8' });
const url = globalThis.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'nsec.txt';
a.style.display = 'none';
document.body.appendChild(a);
a.click();
globalThis.URL.revokeObjectURL(url);
document.body.removeChild(a);
setKeySecured('downloaded');
toast({
title: 'Key Downloaded',
description: 'Your key has been saved to a file.',
});
} catch {
toast({
title: 'Download failed',
description: 'Could not download the key file. Please copy it manually.',
variant: 'destructive',
});
}
};
const copyKey = () => {
navigator.clipboard.writeText(nsec);
setKeySecured('copied');
toast({
title: 'Copied to clipboard',
description: 'Your key has been copied.',
});
};
const finishKeySetup = () => {
try {
login.nsec(nsec);
setStep('profile');
} catch {
toast({
title: 'Login Failed',
description: 'Failed to login with the generated key. Please try again.',
variant: 'destructive',
});
}
};
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = '';
if (!file.type.startsWith('image/')) {
toast({
title: 'Invalid file type',
description: 'Please select an image file.',
variant: 'destructive',
});
return;
}
if (file.size > 5 * 1024 * 1024) {
toast({
title: 'File too large',
description: 'Image must be smaller than 5MB.',
variant: 'destructive',
});
return;
}
try {
const tags = await uploadFile(file);
const url = tags[0]?.[1];
if (url) {
setProfileData(prev => ({ ...prev, picture: url }));
toast({
title: 'Avatar uploaded!',
description: 'Your new avatar is ready.',
});
}
} catch {
toast({
title: 'Upload failed',
description: 'Failed to upload avatar. Please try again.',
variant: 'destructive',
});
}
};
const finishSignup = async (skipProfile = false) => {
try {
if (!skipProfile && (profileData.name || profileData.about || profileData.picture)) {
const metadata: Record<string, string> = {};
if (profileData.name) metadata.name = profileData.name;
if (profileData.about) metadata.about = profileData.about;
if (profileData.picture) metadata.picture = profileData.picture;
await publishEvent({
kind: 0,
content: JSON.stringify(metadata),
});
toast({
title: 'Profile Updated',
description: 'Your profile has been published.',
});
}
setStep('done');
setTimeout(() => {
onClose();
toast({
title: 'Welcome!',
description: 'You are now logged in.',
});
}, 1500);
} catch {
toast({
title: 'Profile Setup Failed',
description: 'Your account was created but profile setup failed. You can update it later.',
variant: 'destructive',
});
setStep('done');
setTimeout(() => {
onClose();
toast({
title: 'Welcome!',
description: 'You are now logged in.',
});
}, 1500);
}
};
useEffect(() => {
if (isOpen) {
setStep('generate');
setIsLoading(false);
setNsec('');
setKeySecured('none');
setProfileData({ name: '', about: '', picture: '' });
}
}, [isOpen]);
const getTitle = () => {
if (step === 'generate') return 'Create Your Account';
if (step === 'download') return 'Save Your Key';
if (step === 'profile') return 'Set Up Your Profile';
return 'Welcome!';
};
const getDescription = () => {
if (step === 'generate') return "We'll generate a secure, private key for your new account.";
if (step === 'download') return "This key is your password. It's the only way to access your account.";
if (step === 'profile') return "Customize your profile. This is how others will see you.";
return "You're all set up and ready to go.";
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className='sm:max-w-md p-0 overflow-hidden rounded-2xl'>
<DialogHeader className='px-6 pt-6 pb-2'>
<DialogTitle className='text-xl font-semibold text-center'>{getTitle()}</DialogTitle>
<DialogDescription className='text-center text-muted-foreground mt-1'>
{getDescription()}
</DialogDescription>
</DialogHeader>
<div className='px-6 py-6 space-y-6'>
{step === 'generate' && (
<div className='text-center space-y-6'>
<div className='p-6 rounded-lg bg-gray-50 dark:bg-gray-800 flex items-center justify-center'>
<Key className='w-16 h-16 text-primary' />
</div>
<p className='text-sm text-gray-600 dark:text-gray-300'>
Click the button below to generate a unique and secure key for your account.
</p>
<Button
className='w-full rounded-full py-6'
onClick={generateKey}
disabled={isLoading}
>
{isLoading ? 'Generating...' : 'Generate My Key'}
</Button>
</div>
)}
{step === 'download' && (
<div className='space-y-6'>
<div className='p-4 rounded-lg border bg-gray-50 dark:bg-gray-800'>
<code className='text-sm break-all'>{nsec}</code>
</div>
<div className='text-sm text-red-600 dark:text-red-400 space-y-2 bg-red-50 dark:bg-red-900/20 p-3 rounded-lg'>
<p className='font-medium'>Important:</p>
<ul className='list-disc pl-5 space-y-1'>
<li>Store this key somewhere safe.</li>
<li>If you lose it, you lose your account.</li>
<li>Never share it with anyone.</li>
</ul>
</div>
<div className='grid grid-cols-2 gap-3'>
<Button variant='outline' onClick={copyKey}>
<Copy className='w-4 h-4 mr-2' />
Copy
</Button>
<Button variant='outline' onClick={downloadKey}>
<Download className='w-4 h-4 mr-2' />
Download
</Button>
</div>
<Button
className='w-full rounded-full py-6'
onClick={finishKeySetup}
disabled={keySecured === 'none'}
>
I've Saved My Key, Continue
</Button>
</div>
)}
{step === 'profile' && (
<div className='space-y-4'>
<div className={`space-y-4 ${isPublishing || isUploading ? 'opacity-50 pointer-events-none' : ''}`}>
<div className='space-y-2'>
<label htmlFor='profile-name' className='text-sm font-medium'>Display Name</label>
<Input
id='profile-name'
value={profileData.name}
onChange={(e) => setProfileData(prev => ({ ...prev, name: e.target.value }))}
placeholder='e.g. Satoshi Nakamoto'
/>
</div>
<div className='space-y-2'>
<label htmlFor='profile-about' className='text-sm font-medium'>About</label>
<Textarea
id='profile-about'
value={profileData.about}
onChange={(e) => setProfileData(prev => ({ ...prev, about: e.target.value }))}
placeholder='A short bio...'
rows={3}
/>
</div>
<div className='space-y-2'>
<label htmlFor='profile-picture' className='text-sm font-medium'>Avatar URL or Upload</label>
<div className='flex gap-2'>
<Input
id='profile-picture'
value={profileData.picture}
onChange={(e) => setProfileData(prev => ({ ...prev, picture: e.target.value }))}
placeholder='https://your-avatar.com/image.jpg'
/>
<input
type='file'
accept='image/*'
className='hidden'
ref={avatarFileInputRef}
onChange={handleAvatarUpload}
/>
<Button
type='button'
variant='outline'
size='icon'
onClick={() => avatarFileInputRef.current?.click()}
disabled={isUploading}
>
{isUploading ? (
<div className='w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin' />
) : (
<Upload className='w-4 h-4' />
)}
</Button>
</div>
</div>
</div>
<div className='flex flex-col space-y-3 pt-4'>
<Button
className='w-full rounded-full py-6'
onClick={() => finishSignup(false)}
disabled={isPublishing || isUploading}
>
{isPublishing ? 'Publishing...' : 'Finish Signup'}
</Button>
<Button
variant='ghost'
className='w-full'
onClick={() => finishSignup(true)}
disabled={isPublishing || isUploading}
>
Skip For Now
</Button>
</div>
</div>
)}
{step === 'done' && (
<div className='flex justify-center items-center py-12'>
<div className='animate-spin rounded-full h-12 w-12 border-b-2 border-primary'></div>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};
export default SignupDialog;