mirror of
https://gitlab.com/soapbox-pub/mkstack.git
synced 2025-08-27 04:59:22 +00:00
attempt to implement signup onboarding flow from surveil/treasures
This commit is contained in:
parent
ad1c8b123c
commit
4e780190cb
@ -1,9 +1,8 @@
|
|||||||
// NOTE: This file is stable and usually should not be modified.
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
// It is important that all functionality in this file is preserved, and should only be modified if explicitly requested.
|
import { Download, Key, Copy, Upload } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import React, { useState } from 'react';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Download, Key } from 'lucide-react';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Button } from '@/components/ui/button.tsx';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -11,8 +10,10 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog.tsx';
|
} from '@/components/ui/dialog.tsx';
|
||||||
import { toast } from '@/hooks/useToast.ts';
|
import { toast } from '@/hooks/useToast';
|
||||||
import { useLoginActions } from '@/hooks/useLoginActions';
|
import { useLoginActions } from '@/hooks/useLoginActions';
|
||||||
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||||
|
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||||
import { generateSecretKey, nip19 } from 'nostr-tools';
|
import { generateSecretKey, nip19 } from 'nostr-tools';
|
||||||
|
|
||||||
interface SignupDialogProps {
|
interface SignupDialogProps {
|
||||||
@ -21,140 +22,348 @@ interface SignupDialogProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
|
const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
|
||||||
const [step, setStep] = useState<'generate' | 'download' | 'done'>('generate');
|
const [step, setStep] = useState<'generate' | 'download' | 'profile' | 'done'>('generate');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [nsec, setNsec] = useState('');
|
const [nsec, setNsec] = useState('');
|
||||||
|
const [keySecured, setKeySecured] = useState<'none' | 'copied' | 'downloaded'>('none');
|
||||||
|
const [profileData, setProfileData] = useState({
|
||||||
|
name: '',
|
||||||
|
about: '',
|
||||||
|
picture: ''
|
||||||
|
});
|
||||||
const login = useLoginActions();
|
const login = useLoginActions();
|
||||||
|
const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish();
|
||||||
|
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||||
|
const avatarFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Generate a proper nsec key using nostr-tools
|
|
||||||
const generateKey = () => {
|
const generateKey = () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
// Generate a new secret key
|
const sk = generateSecretKey();
|
||||||
const sk = generateSecretKey();
|
setNsec(nip19.nsecEncode(sk));
|
||||||
|
setStep('download');
|
||||||
// Convert to nsec format
|
toast({
|
||||||
setNsec(nip19.nsecEncode(sk));
|
title: 'Key Generated',
|
||||||
setStep('download');
|
description: 'Your new key is ready.',
|
||||||
} catch (error) {
|
});
|
||||||
console.error('Failed to generate key:', error);
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
description: 'Failed to generate key. Please try again.',
|
description: 'Failed to generate key. Please try again.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadKey = () => {
|
const downloadKey = () => {
|
||||||
// Create a blob with the key text
|
try {
|
||||||
const blob = new Blob([nsec], { type: 'text/plain' });
|
const blob = new Blob([nsec], { type: 'text/plain; charset=utf-8' });
|
||||||
const url = globalThis.URL.createObjectURL(blob);
|
const url = globalThis.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
// Create a temporary link element and trigger download
|
a.href = url;
|
||||||
const a = document.createElement('a');
|
a.download = 'nsec.txt';
|
||||||
a.href = url;
|
a.style.display = 'none';
|
||||||
a.download = 'nsec.txt';
|
document.body.appendChild(a);
|
||||||
document.body.appendChild(a);
|
a.click();
|
||||||
a.click();
|
globalThis.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
// Clean up
|
setKeySecured('downloaded');
|
||||||
globalThis.URL.revokeObjectURL(url);
|
toast({
|
||||||
document.body.removeChild(a);
|
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({
|
toast({
|
||||||
title: 'Key downloaded',
|
title: 'Copied to clipboard',
|
||||||
description: 'Your key has been downloaded. Keep it safe!',
|
description: 'Your key has been copied.',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const finishSignup = () => {
|
const finishKeySetup = () => {
|
||||||
login.nsec(nsec);
|
try {
|
||||||
|
login.nsec(nsec);
|
||||||
|
setStep('profile');
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: 'Login Failed',
|
||||||
|
description: 'Failed to login with the generated key. Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
setStep('done');
|
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
onClose();
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
toast({
|
e.target.value = '';
|
||||||
title: 'Account created',
|
|
||||||
description: 'You are now logged in.',
|
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 (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className='sm:max-w-md p-0 overflow-hidden rounded-2xl'>
|
<DialogContent className='sm:max-w-md p-0 overflow-hidden rounded-2xl'>
|
||||||
<DialogHeader className='px-6 pt-6 pb-0 relative'>
|
<DialogHeader className='px-6 pt-6 pb-2'>
|
||||||
<DialogTitle className='text-xl font-semibold text-center'>
|
<DialogTitle className='text-xl font-semibold text-center'>{getTitle()}</DialogTitle>
|
||||||
{step === 'generate' && 'Create Your Account'}
|
<DialogDescription className='text-center text-muted-foreground mt-1'>
|
||||||
{step === 'download' && 'Download Your Key'}
|
{getDescription()}
|
||||||
{step === 'done' && 'Setting Up Your Account'}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className='text-center text-muted-foreground mt-2'>
|
|
||||||
{step === 'generate' && 'Generate a secure key for your account'}
|
|
||||||
{step === 'download' && "Keep your key safe - you'll need it to log in"}
|
|
||||||
{step === 'done' && 'Finalizing your account setup'}
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className='px-6 py-8 space-y-6'>
|
<div className='px-6 py-6 space-y-6'>
|
||||||
{step === 'generate' && (
|
{step === 'generate' && (
|
||||||
<div className='text-center space-y-6'>
|
<div className='text-center space-y-6'>
|
||||||
<div className='p-4 rounded-lg bg-gray-50 dark:bg-gray-800 flex items-center justify-center'>
|
<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' />
|
<Key className='w-16 h-16 text-primary' />
|
||||||
</div>
|
</div>
|
||||||
<p className='text-sm text-gray-600 dark:text-gray-300'>
|
<p className='text-sm text-gray-600 dark:text-gray-300'>
|
||||||
We'll generate a secure key for your account. You'll need this key to log in later.
|
Click the button below to generate a unique and secure key for your account.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
className='w-full rounded-full py-6'
|
className='w-full rounded-full py-6'
|
||||||
onClick={generateKey}
|
onClick={generateKey}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{isLoading ? 'Generating key...' : 'Generate my key'}
|
{isLoading ? 'Generating...' : 'Generate My Key'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 'download' && (
|
{step === 'download' && (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
<div className='p-4 rounded-lg border bg-gray-50 dark:bg-gray-800 overflow-auto'>
|
<div className='p-4 rounded-lg border bg-gray-50 dark:bg-gray-800'>
|
||||||
<code className='text-xs break-all'>{nsec}</code>
|
<code className='text-sm break-all'>{nsec}</code>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='text-sm text-gray-600 dark:text-gray-300 space-y-2'>
|
<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 text-red-500'>Important:</p>
|
<p className='font-medium'>Important:</p>
|
||||||
<ul className='list-disc pl-5 space-y-1'>
|
<ul className='list-disc pl-5 space-y-1'>
|
||||||
<li>This is your only way to access your account</li>
|
<li>Store this key somewhere safe.</li>
|
||||||
<li>Store it somewhere safe</li>
|
<li>If you lose it, you lose your account.</li>
|
||||||
<li>Never share this key with anyone</li>
|
<li>Never share it with anyone.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex flex-col space-y-3'>
|
<div className='grid grid-cols-2 gap-3'>
|
||||||
<Button
|
<Button variant='outline' onClick={copyKey}>
|
||||||
variant='outline'
|
<Copy className='w-4 h-4 mr-2' />
|
||||||
className='w-full'
|
Copy
|
||||||
onClick={downloadKey}
|
|
||||||
>
|
|
||||||
<Download className='w-4 h-4 mr-2' />
|
|
||||||
Download Key
|
|
||||||
</Button>
|
</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
|
<Button
|
||||||
className='w-full rounded-full py-6'
|
className='w-full rounded-full py-6'
|
||||||
onClick={finishSignup}
|
onClick={() => finishSignup(false)}
|
||||||
|
disabled={isPublishing || isUploading}
|
||||||
>
|
>
|
||||||
I've saved my key, continue
|
{isPublishing ? 'Publishing...' : 'Finish Signup'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
className='w-full'
|
||||||
|
onClick={() => finishSignup(true)}
|
||||||
|
disabled={isPublishing || isUploading}
|
||||||
|
>
|
||||||
|
Skip For Now
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 'done' && (
|
{step === 'done' && (
|
||||||
<div className='flex justify-center items-center py-8'>
|
<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 className='animate-spin rounded-full h-12 w-12 border-b-2 border-primary'></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user