more steps

This commit is contained in:
Chad Curtis 2025-07-08 06:37:01 +00:00
parent e0ad32447a
commit 4734e96fe7
3 changed files with 314 additions and 300 deletions

View File

@ -5,7 +5,7 @@ import { useState } from 'react';
import { User } from 'lucide-react'; import { User } from 'lucide-react';
import { Button } from '@/components/ui/button.tsx'; import { Button } from '@/components/ui/button.tsx';
import LoginDialog from './LoginDialog'; import LoginDialog from './LoginDialog';
import SignupDialog from './SignupDialog';
import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts'; import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts';
import { AccountSwitcher } from './AccountSwitcher'; import { AccountSwitcher } from './AccountSwitcher';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -17,11 +17,9 @@ export interface LoginAreaProps {
export function LoginArea({ className }: LoginAreaProps) { export function LoginArea({ className }: LoginAreaProps) {
const { currentUser } = useLoggedInAccounts(); const { currentUser } = useLoggedInAccounts();
const [loginDialogOpen, setLoginDialogOpen] = useState(false); const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [signupDialogOpen, setSignupDialogOpen] = useState(false);
const handleLogin = () => { const handleLogin = () => {
setLoginDialogOpen(false); setLoginDialogOpen(false);
setSignupDialogOpen(false);
}; };
return ( return (
@ -42,13 +40,9 @@ export function LoginArea({ className }: LoginAreaProps) {
isOpen={loginDialogOpen} isOpen={loginDialogOpen}
onClose={() => setLoginDialogOpen(false)} onClose={() => setLoginDialogOpen(false)}
onLogin={handleLogin} onLogin={handleLogin}
onSignup={() => setSignupDialogOpen(true)}
/> />
<SignupDialog
isOpen={signupDialogOpen}
onClose={() => setSignupDialogOpen(false)}
/>
</div> </div>
); );
} }

View File

@ -14,18 +14,19 @@ import {
} from '@/components/ui/dialog.tsx'; } from '@/components/ui/dialog.tsx';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs.tsx'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs.tsx';
import { useLoginActions } from '@/hooks/useLoginActions'; import { useLoginActions } from '@/hooks/useLoginActions';
import SignupDialog from './SignupDialog';
interface LoginDialogProps { interface LoginDialogProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onLogin: () => void; onLogin: () => void;
onSignup?: () => void;
} }
const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onSignup }) => { const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin }) => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [nsec, setNsec] = useState(''); const [nsec, setNsec] = useState('');
const [bunkerUri, setBunkerUri] = useState(''); const [bunkerUri, setBunkerUri] = useState('');
const [isSignupOpen, setIsSignupOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const login = useLoginActions(); const login = useLoginActions();
@ -89,12 +90,11 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
const handleSignupClick = () => { const handleSignupClick = () => {
onClose(); onClose();
if (onSignup) { setIsSignupOpen(true);
onSignup();
}
}; };
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-0 relative'>
@ -199,7 +199,6 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
</TabsContent> </TabsContent>
</Tabs> </Tabs>
{onSignup && (
<div className='relative mt-6'> <div className='relative mt-6'>
<div className='absolute inset-0 flex items-center' aria-hidden='true'> <div className='absolute inset-0 flex items-center' aria-hidden='true'>
<div className='w-full border-t border-border' /> <div className='w-full border-t border-border' />
@ -210,9 +209,7 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
</span> </span>
</div> </div>
</div> </div>
)}
{onSignup && (
<div className='mt-6'> <div className='mt-6'>
<Button <Button
variant='outline' variant='outline'
@ -222,10 +219,11 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
Create an account Create an account
</Button> </Button>
</div> </div>
)}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<SignupDialog isOpen={isSignupOpen} onClose={() => setIsSignupOpen(false)} />
</>
); );
}; };

View File

@ -1,22 +1,13 @@
// NOTE: This file is stable and usually should not be modified. import React, { useState, useEffect } from 'react';
// It is important that all functionality in this file is preserved, and should only be modified if explicitly requested. import { Download, KeyRound, Lock, CheckCircle, Copy, ArrowRight } from 'lucide-react';
import React, { useState, useEffect, useRef } from 'react';
import { Download, Key, Copy, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
Dialog, import { Card, CardContent } from '@/components/ui/card';
DialogContent, import { useToast } from '@/hooks/useToast';
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog.tsx';
import { toast } from '@/hooks/useToast';
import { useLoginActions } from '@/hooks/useLoginActions'; import { useLoginActions } from '@/hooks/useLoginActions';
import { useNostrPublish } from '@/hooks/useNostrPublish'; 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 {
@ -25,19 +16,17 @@ interface SignupDialogProps {
} }
const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => { const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
const [step, setStep] = useState<'generate' | 'download' | 'profile' | 'done'>('generate'); const [step, setStep] = useState<'welcome' | 'generate' | 'download' | 'profile' | 'done'>('welcome');
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 [keySecured, setKeySecured] = useState<'none' | 'copied' | 'downloaded'>('none');
const [profileData, setProfileData] = useState({ const [profileData, setProfileData] = useState({
name: '', name: '',
about: '', about: '',
picture: ''
}); });
const { toast } = useToast();
const login = useLoginActions(); const login = useLoginActions();
const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish(); const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const avatarFileInputRef = useRef<HTMLInputElement>(null);
const generateKey = () => { const generateKey = () => {
setIsLoading(true); setIsLoading(true);
@ -47,8 +36,8 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
setNsec(nip19.nsecEncode(sk)); setNsec(nip19.nsecEncode(sk));
setStep('download'); setStep('download');
toast({ toast({
title: 'Key Generated', title: 'New key generated',
description: 'Your new key is ready.', description: 'Keep it safe.',
}); });
} catch { } catch {
toast({ toast({
@ -59,25 +48,27 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, 500); }, 1000);
}; };
const downloadKey = () => { const downloadKey = () => {
try { try {
const blob = new Blob([nsec], { type: 'text/plain; charset=utf-8' }); const blob = new Blob([nsec], { type: 'text/plain; charset=utf-8' });
const url = globalThis.URL.createObjectURL(blob); const url = globalThis.URL.createObjectURL(blob);
const filename = 'secret-key.txt';
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = 'nsec.txt'; a.download = filename;
a.style.display = 'none'; a.style.display = 'none';
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
globalThis.URL.revokeObjectURL(url); globalThis.URL.revokeObjectURL(url);
document.body.removeChild(a); document.body.removeChild(a);
setKeySecured('downloaded'); setKeySecured('downloaded');
toast({ toast({
title: 'Key Downloaded', title: 'Key Secured',
description: 'Your key has been saved to a file.', description: 'Your key has been safely stored.',
}); });
} catch { } catch {
toast({ toast({
@ -110,56 +101,12 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
} }
}; };
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) => { const finishSignup = async (skipProfile = false) => {
try { try {
if (!skipProfile && (profileData.name || profileData.about || profileData.picture)) { if (!skipProfile && (profileData.name || profileData.about)) {
const metadata: Record<string, string> = {}; const metadata: Record<string, string> = {};
if (profileData.name) metadata.name = profileData.name; if (profileData.name) metadata.name = profileData.name;
if (profileData.about) metadata.about = profileData.about; if (profileData.about) metadata.about = profileData.about;
if (profileData.picture) metadata.picture = profileData.picture;
await publishEvent({ await publishEvent({
kind: 0, kind: 0,
@ -167,8 +114,8 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
}); });
toast({ toast({
title: 'Profile Updated', title: 'Profile Created',
description: 'Your profile has been published.', description: 'Your profile has been created.',
}); });
} }
@ -179,7 +126,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
title: 'Welcome!', title: 'Welcome!',
description: 'You are now logged in.', description: 'You are now logged in.',
}); });
}, 1500); }, 2000);
} catch { } catch {
toast({ toast({
@ -195,178 +142,253 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose }) => {
title: 'Welcome!', title: 'Welcome!',
description: 'You are now logged in.', description: 'You are now logged in.',
}); });
}, 1500); }, 2000);
} }
}; };
useEffect(() => {
if (isOpen) {
setStep('generate');
setIsLoading(false);
setNsec('');
setKeySecured('none');
setProfileData({ name: '', about: '', picture: '' });
}
}, [isOpen]);
const getTitle = () => { const getTitle = () => {
if (step === 'generate') return 'Create Your Account'; if (step === 'welcome') return 'Welcome';
if (step === 'download') return 'Save Your Key'; if (step === 'generate') return 'Generate Your Key';
if (step === 'profile') return 'Set Up Your Profile'; if (step === 'download') return 'Secure Your Key';
if (step === 'profile') return 'Create Your Profile';
return 'Welcome!'; return 'Welcome!';
}; };
const getDescription = () => { const getDescription = () => {
if (step === 'generate') return "We'll generate a secure, private key for your new account."; if (step === 'welcome') return 'Ready to get started?';
if (step === 'download') return "This key is your password. It's the only way to access your account."; if (step === 'generate') return 'A new key is being generated for you.';
if (step === 'profile') return "Customize your profile. This is how others will see you."; if (step === 'download') return "This key is your password. Keep it safe.";
return "You're all set up and ready to go."; if (step === 'profile') return 'This is how others will see you.';
return 'You are now logged in.';
}; };
useEffect(() => {
if (isOpen) {
setStep('welcome');
setIsLoading(false);
setNsec('');
setKeySecured('none');
setProfileData({ name: '', about: '' });
}
}, [isOpen]);
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>
<DialogHeader className='px-6 pt-6 pb-2'> <DialogHeader>
<DialogTitle className='text-xl font-semibold text-center'>{getTitle()}</DialogTitle> <DialogTitle>{getTitle()}</DialogTitle>
<DialogDescription className='text-center text-muted-foreground mt-1'> <DialogDescription>{getDescription()}</DialogDescription>
{getDescription()}
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className='space-y-4'>
<div className='px-6 py-6 space-y-6'> {step === 'welcome' && (
{step === 'generate' && ( <div className='text-center space-y-4'>
<div className='text-center space-y-6'> <p>
<div className='p-6 rounded-lg bg-gray-50 dark:bg-gray-800 flex items-center justify-center'> Your key is your identity. It's how you log in and how others see you.
<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> </p>
<Button <Button
className='w-full rounded-full py-6' className='w-full'
onClick={generateKey} onClick={() => setStep('generate')}
disabled={isLoading}
> >
{isLoading ? 'Generating...' : 'Generate My Key'} <ArrowRight className='w-5 h-5 mr-2' />
Get Started
</Button> </Button>
</div> </div>
)} )}
{step === 'generate' && (
<div className='text-center space-y-4'>
{isLoading ? (
<div className='space-y-3'>
<div className='relative'>
<KeyRound className='w-20 h-20 mx-auto animate-pulse' />
</div>
<p className='text-lg font-semibold'>
Generating your key...
</p>
</div>
) : (
<div className='space-y-3'>
<KeyRound className='w-20 h-20 mx-auto' />
<p className='text-lg font-semibold'>
Ready to generate your key?
</p>
</div>
)}
{!isLoading && (
<Button
className='w-full'
onClick={generateKey}
disabled={isLoading}
>
Generate My Key
</Button>
)}
</div>
)}
{step === 'download' && ( {step === 'download' && (
<div className='space-y-6'> <div className='space-y-4'>
<div className='p-4 rounded-lg border bg-gray-50 dark:bg-gray-800'> <div className='relative p-3 bg-muted rounded-xl border'>
<code className='text-sm break-all'>{nsec}</code> <div className='flex items-center gap-2 mb-2'>
<Lock className='w-4 h-4' />
<span className='text-sm font-medium'>
Your Secret Key
</span>
</div>
<div className='p-2 bg-background rounded-lg border'>
<code className='text-xs break-all font-mono'>{nsec}</code>
</div>
</div> </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'> <div className='space-y-3'>
<p className='font-medium'>Important:</p> <p className='text-sm font-medium text-center'>
<ul className='list-disc pl-5 space-y-1'> Choose how to secure your key:
<li>Store this key somewhere safe.</li> </p>
<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'> <div className='grid grid-cols-1 gap-2'>
<Button variant='outline' onClick={copyKey}> <Card className={`cursor-pointer transition-all duration-200 ${
<Copy className='w-4 h-4 mr-2' /> keySecured === 'copied'
Copy ? 'ring-2 ring-primary'
: 'hover:bg-muted'
}`}>
<CardContent className='p-3'>
<Button
variant="ghost"
className='w-full h-auto p-0 justify-start'
onClick={copyKey}
>
<div className='flex items-center gap-3 w-full'>
<div className={`p-1.5 rounded-lg ${
keySecured === 'copied'
? 'bg-primary/20'
: 'bg-muted'
}`}>
{keySecured === 'copied' ? (
<CheckCircle className='w-4 h-4 text-primary' />
) : (
<Copy className='w-4 h-4' />
)}
</div>
<div className='flex-1 text-left'>
<div className='font-medium text-sm'>
Copy to Clipboard
</div>
</div>
{keySecured === 'copied' && (
<div className='text-xs font-medium text-primary'>
Copied
</div>
)}
</div>
</Button> </Button>
<Button variant='outline' onClick={downloadKey}> </CardContent>
<Download className='w-4 h-4 mr-2' /> </Card>
Download
<Card className={`cursor-pointer transition-all duration-200 ${
keySecured === 'downloaded'
? 'ring-2 ring-primary'
: 'hover:bg-muted'
}`}>
<CardContent className='p-3'>
<Button
variant="ghost"
className='w-full h-auto p-0 justify-start'
onClick={downloadKey}
>
<div className='flex items-center gap-3 w-full'>
<div className={`p-1.5 rounded-lg ${
keySecured === 'downloaded'
? 'bg-primary/20'
: 'bg-muted'
}`}>
{keySecured === 'downloaded' ? (
<CheckCircle className='w-4 h-4 text-primary' />
) : (
<Download className='w-4 h-4' />
)}
</div>
<div className='flex-1 text-left'>
<div className='font-medium text-sm'>
Download as File
</div>
</div>
{keySecured === 'downloaded' && (
<div className='text-xs font-medium text-primary'>
Downloaded
</div>
)}
</div>
</Button> </Button>
</CardContent>
</Card>
</div> </div>
<Button <Button
className='w-full rounded-full py-6' className='w-full'
onClick={finishKeySetup} onClick={finishKeySetup}
disabled={keySecured === 'none'} disabled={keySecured === 'none'}
> >
I've Saved My Key, Continue <Lock className='w-4 h-4 mr-2' />
Continue
</Button> </Button>
</div> </div>
</div>
)} )}
{step === 'profile' && ( {step === 'profile' && (
<div className='space-y-4'> <div className='space-y-4'>
<div className={`space-y-4 ${isPublishing || isUploading ? 'opacity-50 pointer-events-none' : ''}`}> <div className={`space-y-4 text-left ${isPublishing ? 'opacity-50 pointer-events-none' : ''}`}>
<div className='space-y-2'> <div className='space-y-2'>
<label htmlFor='profile-name' className='text-sm font-medium'>Display Name</label> <label htmlFor='profile-name' className='text-sm font-medium'>
Display Name
</label>
<Input <Input
id='profile-name' id='profile-name'
value={profileData.name} value={profileData.name}
onChange={(e) => setProfileData(prev => ({ ...prev, name: e.target.value }))} onChange={(e) => setProfileData(prev => ({ ...prev, name: e.target.value }))}
placeholder='e.g. Satoshi Nakamoto' placeholder='Your name'
disabled={isPublishing}
/> />
</div> </div>
<div className='space-y-2'> <div className='space-y-2'>
<label htmlFor='profile-about' className='text-sm font-medium'>About</label> <label htmlFor='profile-about' className='text-sm font-medium'>
Bio
</label>
<Textarea <Textarea
id='profile-about' id='profile-about'
value={profileData.about} value={profileData.about}
onChange={(e) => setProfileData(prev => ({ ...prev, about: e.target.value }))} onChange={(e) => setProfileData(prev => ({ ...prev, about: e.target.value }))}
placeholder='A short bio...' placeholder='Tell others about yourself...'
rows={3} rows={3}
disabled={isPublishing}
/> />
</div> </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>
<div className='flex flex-col space-y-3 pt-4'> <div className='space-y-3'>
<Button <Button
className='w-full rounded-full py-6' className='w-full'
onClick={() => finishSignup(false)} onClick={() => finishSignup(false)}
disabled={isPublishing || isUploading} disabled={isPublishing}
> >
{isPublishing ? 'Publishing...' : 'Finish Signup'} {isPublishing ? 'Creating Profile...' : 'Create Profile & Finish'}
</Button> </Button>
<Button <Button
variant='ghost' variant='outline'
className='w-full' className='w-full'
onClick={() => finishSignup(true)} onClick={() => finishSignup(true)}
disabled={isPublishing || isUploading} disabled={isPublishing}
> >
Skip For Now Skip for now
</Button> </Button>
</div> </div>
</div> </div>
)} )}
{step === 'done' && ( {step === 'done' && (
<div className='flex justify-center items-center py-12'> <div className='flex justify-center items-center py-8'>
<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>
)} )}