diff --git a/src/components/auth/SignupDialog.tsx b/src/components/auth/SignupDialog.tsx index 61154e8..efe744b 100644 --- a/src/components/auth/SignupDialog.tsx +++ b/src/components/auth/SignupDialog.tsx @@ -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 = ({ 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 = ({ 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(null); const handleGenerateKey = () => { setStep('keygen'); @@ -58,17 +76,47 @@ const SignupDialog: React.FC = ({ 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 = ({ isOpen, onClose, onLogin, o } }; + const handleAvatarUpload = async (e: React.ChangeEvent) => { + 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 = {}; + 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 = ({ isOpen, onClose, onLogin, o > - Sign up + {getTitle()} - +
+ {/* Initial Key Step - Soapbox style */} {step === 'key' && (

@@ -111,16 +284,16 @@ const SignupDialog: React.FC = ({ isOpen, onClose, onLogin, o

- -
)} + {/* Key Generation Step */} {step === 'keygen' && (
@@ -147,6 +321,7 @@ const SignupDialog: React.FC = ({ isOpen, onClose, onLogin, o
)} + {/* Download Step */} {step === 'download' && (
@@ -154,32 +329,205 @@ const SignupDialog: React.FC = ({ isOpen, onClose, onLogin, o Your secret key has been generated!

- Your key is ready to use. + Please download and save your key securely.

-
- ✅ +
+ + + + + + +
- - - -
)} + {/* Profile Setup Step */} + {step === 'profile' && ( +
+
+

+ Almost there! Let's set up your profile +

+

+ Your profile is your identity on Nostr. +

+
+ {/* Publishing status indicator */} + {isPublishing && ( +
+
+
+ + Publishing your profile... + +
+
+ )} + + {/* Profile form */} +
+
+ + setProfileData(prev => ({ ...prev, name: e.target.value }))} + placeholder='Your name' + className='rounded-lg' + disabled={isPublishing} + /> +
+ +
+ +