mirror of
https://gitlab.com/soapbox-pub/mkstack.git
synced 2025-09-24 02:06:07 +00:00
add back tutorial when generating a new key
This commit is contained in:
parent
a0bd6729ae
commit
3db5148780
@ -1,13 +1,18 @@
|
||||
// NOTE: This file is stable and usually should not be modified.
|
||||
// It is important that all functionality in this file is preserved, and should only be modified if explicitly requested.
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Download, CheckCircle, User, 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, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
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';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SignupDialogProps {
|
||||
@ -18,10 +23,23 @@ interface SignupDialogProps {
|
||||
onOpenLogin?: () => void;
|
||||
}
|
||||
|
||||
const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onLogin, onOpenLogin }) => {
|
||||
const [step, setStep] = useState<'key' | 'keygen' | 'download'>('key');
|
||||
const sanitizeFilename = (filename: string) => {
|
||||
return filename.replace(/[^a-z0-9_.-]/gi, '_');
|
||||
}
|
||||
|
||||
const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete, onLogin, onOpenLogin }) => {
|
||||
const [step, setStep] = useState<'key' | 'keygen' | 'download' | 'profile' | 'done'>('key');
|
||||
const [nsec, setNsec] = useState('');
|
||||
const [keySecured, setKeySecured] = useState<'none' | '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 handleGenerateKey = () => {
|
||||
setStep('keygen');
|
||||
@ -58,17 +76,47 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onLogin, o
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseKey = () => {
|
||||
const downloadKey = () => {
|
||||
try {
|
||||
// Create a blob with the key text
|
||||
const blob = new Blob([nsec], { type: 'text/plain; charset=utf-8' });
|
||||
const url = globalThis.URL.createObjectURL(blob);
|
||||
|
||||
// Sanitize filename
|
||||
const filename = sanitizeFilename('nostr-nsec-key.txt');
|
||||
|
||||
// Create a temporary link element and trigger download
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.style.display = 'none';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
// Clean up immediately
|
||||
globalThis.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
// Mark as secured
|
||||
setKeySecured('downloaded');
|
||||
|
||||
toast({
|
||||
title: 'Secret Key Saved!',
|
||||
description: 'Your key has been safely stored.',
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Download failed',
|
||||
description: 'Could not download the key file. Please copy it manually.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const finishKeySetup = () => {
|
||||
try {
|
||||
login.nsec(nsec);
|
||||
if (onLogin) {
|
||||
onLogin();
|
||||
}
|
||||
onClose();
|
||||
toast({
|
||||
title: 'Welcome!',
|
||||
description: 'Your account is ready.',
|
||||
});
|
||||
setStep('profile');
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Login Failed',
|
||||
@ -78,13 +126,137 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onLogin, o
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Reset file input
|
||||
e.target.value = '';
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast({
|
||||
title: 'Invalid file type',
|
||||
description: 'Please select an image file for your avatar.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast({
|
||||
title: 'File too large',
|
||||
description: 'Avatar image must be smaller than 5MB.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tags = await uploadFile(file);
|
||||
// Get the URL from the first tag
|
||||
const url = tags[0]?.[1];
|
||||
if (url) {
|
||||
setProfileData(prev => ({ ...prev, picture: url }));
|
||||
toast({
|
||||
title: 'Avatar uploaded!',
|
||||
description: 'Your avatar has been uploaded successfully.',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Upload failed',
|
||||
description: 'Failed to upload avatar. Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const finishSignup = async (skipProfile = false) => {
|
||||
// Mark signup completion time for fallback welcome modal
|
||||
localStorage.setItem('signup_completed', Date.now().toString());
|
||||
|
||||
try {
|
||||
// Publish profile if user provided information
|
||||
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 Created!',
|
||||
description: 'Your profile has been set up.',
|
||||
});
|
||||
}
|
||||
|
||||
// Close signup and show welcome modal
|
||||
onClose();
|
||||
if (onComplete) {
|
||||
// Add a longer delay to ensure login state has fully propagated
|
||||
setTimeout(() => {
|
||||
onComplete();
|
||||
}, 600);
|
||||
} else {
|
||||
// Fallback for when used without onComplete
|
||||
setStep('done');
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
toast({
|
||||
title: 'Welcome!',
|
||||
description: 'Your account is ready.',
|
||||
});
|
||||
}, 3000);
|
||||
}
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Profile Setup Failed',
|
||||
description: 'Your account was created but profile setup failed. You can update it later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
// Still proceed to completion even if profile failed
|
||||
onClose();
|
||||
if (onComplete) {
|
||||
// Add a longer delay to ensure login state has fully propagated
|
||||
setTimeout(() => {
|
||||
onComplete();
|
||||
}, 600);
|
||||
} else {
|
||||
// Fallback for when used without onComplete
|
||||
setStep('done');
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
toast({
|
||||
title: 'Welcome!',
|
||||
description: 'Your account is ready.',
|
||||
});
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
if (step === 'key') return 'Sign up';
|
||||
if (step === 'keygen') return 'Generating Your Key';
|
||||
if (step === 'download') return 'Secret Key';
|
||||
if (step === 'profile') return 'Create Your Profile';
|
||||
return 'Welcome!';
|
||||
};
|
||||
|
||||
// Reset state when dialog opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setStep('key');
|
||||
setNsec('');
|
||||
setKeySecured('none');
|
||||
setProfileData({ name: '', about: '', picture: '' });
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
@ -95,11 +267,12 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onLogin, o
|
||||
>
|
||||
<DialogHeader className={cn('px-6 pt-6 pb-1 relative flex-shrink-0')}>
|
||||
<DialogTitle className={cn('font-semibold text-center text-lg')}>
|
||||
Sign up
|
||||
{getTitle()}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
<div className='px-6 pt-2 pb-6 space-y-6 overflow-y-scroll flex-1'>
|
||||
{/* Initial Key Step - Soapbox style */}
|
||||
{step === 'key' && (
|
||||
<div className='flex flex-col items-center space-y-6'>
|
||||
<p className='text-center font-semibold'>
|
||||
@ -111,16 +284,16 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onLogin, o
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col items-center space-y-3 w-full'>
|
||||
<Button
|
||||
<Button
|
||||
className='bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-6 rounded-lg'
|
||||
size='lg'
|
||||
size='lg'
|
||||
onClick={handleGenerateKey}
|
||||
>
|
||||
Generate key
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant='ghost'
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
onClick={handleHasKey}
|
||||
>
|
||||
@ -130,6 +303,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onLogin, o
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key Generation Step */}
|
||||
{step === 'keygen' && (
|
||||
<div className='text-center space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
@ -147,6 +321,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onLogin, o
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download Step */}
|
||||
{step === 'download' && (
|
||||
<div className='text-center space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
@ -154,32 +329,205 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onLogin, o
|
||||
Your secret key has been generated!
|
||||
</p>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
Your key is ready to use.
|
||||
Please download and save your key securely.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='text-6xl'>
|
||||
✅
|
||||
<div className='space-y-3'>
|
||||
<Card className={`cursor-pointer transition-all duration-200 ${
|
||||
keySecured === 'downloaded'
|
||||
? 'ring-2 ring-green-500 bg-green-50 dark:bg-green-950/20'
|
||||
: 'hover:bg-primary/5 hover:border-primary/20'
|
||||
}`}>
|
||||
<CardContent className='p-3'>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className='w-full h-auto p-0 justify-start hover:bg-transparent'
|
||||
onClick={downloadKey}
|
||||
>
|
||||
<div className='flex items-center gap-3 w-full'>
|
||||
<div className={`p-1.5 rounded-lg ${
|
||||
keySecured === 'downloaded'
|
||||
? 'bg-green-100 dark:bg-green-900'
|
||||
: 'bg-primary/10'
|
||||
}`}>
|
||||
{keySecured === 'downloaded' ? (
|
||||
<CheckCircle className='w-4 h-4 text-green-600' />
|
||||
) : (
|
||||
<Download className='w-4 h-4 text-primary' />
|
||||
)}
|
||||
</div>
|
||||
<div className='flex-1 text-left'>
|
||||
<div className='font-medium text-sm'>
|
||||
Download as File
|
||||
</div>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
Save as nostr-nsec-key.txt file
|
||||
</div>
|
||||
</div>
|
||||
{keySecured === 'downloaded' && (
|
||||
<div className='text-xs font-medium text-green-600'>
|
||||
✓ Downloaded
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
className={`w-full rounded-full py-4 text-base font-semibold transform transition-all duration-200 shadow-lg ${
|
||||
keySecured === 'downloaded'
|
||||
? 'bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 hover:scale-105'
|
||||
: 'bg-gradient-to-r from-blue-600/60 to-indigo-600/60 text-muted cursor-not-allowed'
|
||||
}`}
|
||||
onClick={finishKeySetup}
|
||||
disabled={keySecured !== 'downloaded'}
|
||||
>
|
||||
<span className="text-center leading-tight">
|
||||
{keySecured === 'none' ? (
|
||||
'Please download your key first'
|
||||
) : (
|
||||
'My Key is Safe - Continue'
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className='w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-6 rounded-lg'
|
||||
onClick={handleUseKey}
|
||||
>
|
||||
Continue with generated key
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setStep('key')}
|
||||
className='w-full'
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile Setup Step */}
|
||||
{step === 'profile' && (
|
||||
<div className='text-center space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<p className='text-lg font-semibold'>
|
||||
Almost there! Let's set up your profile
|
||||
</p>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
Your profile is your identity on Nostr.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Publishing status indicator */}
|
||||
{isPublishing && (
|
||||
<div className='relative p-4 rounded-xl bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/30 dark:to-indigo-950/30 border border-blue-200 dark:border-blue-800'>
|
||||
<div className='flex items-center justify-center gap-3'>
|
||||
<div className='w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin' />
|
||||
<span className='text-sm font-medium text-blue-700 dark:text-blue-300'>
|
||||
Publishing your profile...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile form */}
|
||||
<div className={`space-y-4 text-left ${isPublishing ? '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='Your name'
|
||||
className='rounded-lg'
|
||||
disabled={isPublishing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<label htmlFor='profile-about' className='text-sm font-medium'>
|
||||
Bio
|
||||
</label>
|
||||
<Textarea
|
||||
id='profile-about'
|
||||
value={profileData.about}
|
||||
onChange={(e) => setProfileData(prev => ({ ...prev, about: e.target.value }))}
|
||||
placeholder='Tell others about yourself...'
|
||||
className='rounded-lg resize-none'
|
||||
rows={3}
|
||||
disabled={isPublishing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<label htmlFor='profile-picture' className='text-sm font-medium'>
|
||||
Avatar
|
||||
</label>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
id='profile-picture'
|
||||
value={profileData.picture}
|
||||
onChange={(e) => setProfileData(prev => ({ ...prev, picture: e.target.value }))}
|
||||
placeholder='https://example.com/your-avatar.jpg'
|
||||
className='rounded-lg flex-1'
|
||||
disabled={isPublishing}
|
||||
/>
|
||||
<input
|
||||
type='file'
|
||||
accept='image/*'
|
||||
className='hidden'
|
||||
ref={avatarFileInputRef}
|
||||
onChange={handleAvatarUpload}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={() => avatarFileInputRef.current?.click()}
|
||||
disabled={isUploading || isPublishing}
|
||||
className='rounded-lg shrink-0'
|
||||
title='Upload avatar image'
|
||||
>
|
||||
{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>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className='space-y-3'>
|
||||
<Button
|
||||
className='w-full rounded-full py-4 text-base font-semibold bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 transform transition-all duration-200 hover:scale-105 shadow-lg disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none'
|
||||
onClick={() => finishSignup(false)}
|
||||
disabled={isPublishing || isUploading}
|
||||
>
|
||||
{isPublishing ? (
|
||||
<>
|
||||
<div className='w-4 h-4 mr-2 border-2 border-current border-t-transparent rounded-full animate-spin' />
|
||||
Creating Profile...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<User className='w-4 h-4 mr-2' />
|
||||
Create Profile & Finish
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full rounded-full py-3 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
onClick={() => finishSignup(true)}
|
||||
disabled={isPublishing || isUploading}
|
||||
>
|
||||
{isPublishing ? (
|
||||
<>
|
||||
<div className='w-4 h-4 mr-2 border-2 border-current border-t-transparent rounded-full animate-spin' />
|
||||
Setting up account...
|
||||
</>
|
||||
) : (
|
||||
'Skip for now'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
Loading…
x
Reference in New Issue
Block a user