diff --git a/CONTEXT.md b/CONTEXT.md index b55e6f8..6ab603e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -51,7 +51,7 @@ The project uses shadcn/ui components located in `@/components/ui`. These are un - **Badge**: Small status descriptors for UI elements - **Breadcrumb**: Navigation aid showing current location in hierarchy - **Button**: Customizable button with multiple variants and sizes -- **Calendar**: Date picker component +- **Calendar**: Date picker component - **Card**: Container with header, content, and footer sections - **Carousel**: Slideshow for cycling through elements - **Chart**: Data visualization component @@ -174,7 +174,7 @@ When designing tags for Nostr events, follow these principles: ```json // ❌ Wrong: Multi-letter tag, not queryable at relay level ["product_type", "electronics"] - + // ✅ Correct: Single-letter tag, relay-indexed and queryable ["t", "electronics"] ["t", "smartphone"] @@ -186,7 +186,7 @@ When designing tags for Nostr events, follow these principles: // ❌ Inefficient: Get all events, filter in JavaScript const events = await nostr.query([{ kinds: [30402] }]); const filtered = events.filter(e => hasTag(e, 'product_type', 'electronics')); - + // ✅ Efficient: Filter at relay level const events = await nostr.query([{ kinds: [30402], '#t': ['electronics'] }]); ``` @@ -202,17 +202,17 @@ For applications focused on a specific community or niche, you can use `t` tags **Implementation:** ```typescript // Publishing with community tag -createEvent({ - kind: 1, +createEvent({ + kind: 1, content: data.content, tags: [['t', 'farming']] }); // Querying community content -const events = await nostr.query([{ - kinds: [1], +const events = await nostr.query([{ + kinds: [1], '#t': ['farming'], - limit: 20 + limit: 20 }], { signal }); ``` @@ -382,7 +382,7 @@ function useCalendarEvents() { queryFn: async (c) => { const signal = AbortSignal.any([c.signal, AbortSignal.timeout(1500)]); const events = await nostr.query([{ kinds: [31922, 31923], limit: 20 }], { signal }); - + // Filter events through validator to ensure they meet NIP-52 requirements return events.filter(validateCalendarEvent); }, @@ -490,9 +490,9 @@ function MyComponent() { } ``` -The `LoginArea` component handles all the login-related UI and interactions, including displaying login dialogs and switching between accounts. It should not be wrapped in any conditional logic. +The `LoginArea` component handles all the login-related UI and interactions, including displaying login dialogs, sign up functionality, and switching between accounts. It should not be wrapped in any conditional logic. -`LoginArea` displays a "Log in" button when the user is logged out, and changes to an account switcher once the user is logged in. It is an inline-flex element by default. To make it expand to the width of its container, you can pass a className like `flex` (to make it a block element) or `w-full`. If it is left as inline-flex, it's recommended to set a max width. +`LoginArea` displays both "Log in" and "Sign Up" buttons when the user is logged out, and changes to an account switcher once the user is logged in. It is an inline-flex element by default. To make it expand to the width of its container, you can pass a className like `flex` (to make it a block element) or `w-full`. If it is left as inline-flex, it's recommended to set a max width. ### `npub`, `naddr`, and other Nostr addresses @@ -869,14 +869,14 @@ import { Card, CardContent } from '@/components/ui/card'; To add custom fonts, follow these steps: 1. **Install a font package** using the `js-dev__npm_add_package` tool: - + **Any Google Font can be installed** using the @fontsource packages. Examples: - For Inter Variable: `js-dev__npm_add_package({ name: "@fontsource-variable/inter" })` - For Roboto: `js-dev__npm_add_package({ name: "@fontsource/roboto" })` - For Outfit Variable: `js-dev__npm_add_package({ name: "@fontsource-variable/outfit" })` - For Poppins: `js-dev__npm_add_package({ name: "@fontsource/poppins" })` - For Open Sans: `js-dev__npm_add_package({ name: "@fontsource/open-sans" })` - + **Format**: `@fontsource/[font-name]` or `@fontsource-variable/[font-name]` (for variable fonts) 2. **Import the font** in `src/main.tsx`: @@ -900,7 +900,7 @@ To add custom fonts, follow these steps: ### Recommended Font Choices by Use Case - **Modern/Clean**: Inter Variable, Outfit Variable, or Manrope -- **Professional/Corporate**: Roboto, Open Sans, or Source Sans Pro +- **Professional/Corporate**: Roboto, Open Sans, or Source Sans Pro - **Creative/Artistic**: Poppins, Nunito, or Comfortaa - **Technical/Code**: JetBrains Mono, Fira Code, or Source Code Pro (for monospace) diff --git a/src/components/auth/LoginArea.tsx b/src/components/auth/LoginArea.tsx index 40d2f53..f84414a 100644 --- a/src/components/auth/LoginArea.tsx +++ b/src/components/auth/LoginArea.tsx @@ -2,7 +2,7 @@ // It is important that all functionality in this file is preserved, and should only be modified if explicitly requested. import { useState } from 'react'; -import { User } from 'lucide-react'; +import { User, UserPlus } from 'lucide-react'; import { Button } from '@/components/ui/button.tsx'; import LoginDialog from './LoginDialog'; import SignupDialog from './SignupDialog'; @@ -29,18 +29,27 @@ export function LoginArea({ className }: LoginAreaProps) { {currentUser ? ( setLoginDialogOpen(true)} /> ) : ( - +
+ +
)} setLoginDialogOpen(false)} + isOpen={loginDialogOpen} + onClose={() => setLoginDialogOpen(false)} onLogin={handleLogin} onSignup={() => setSignupDialogOpen(true)} /> diff --git a/src/components/auth/LoginDialog.tsx b/src/components/auth/LoginDialog.tsx index 5e7e49c..ff40ec0 100644 --- a/src/components/auth/LoginDialog.tsx +++ b/src/components/auth/LoginDialog.tsx @@ -1,19 +1,15 @@ // 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, { useRef, useState } from 'react'; -import { Shield, Upload } from 'lucide-react'; -import { Button } from '@/components/ui/button.tsx'; -import { Input } from '@/components/ui/input.tsx'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog.tsx'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs.tsx'; +import React, { useRef, useState, useEffect } from 'react'; +import { Shield, Upload, AlertTriangle, UserPlus, KeyRound, Sparkles, Cloud } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Alert, AlertDescription } from '@/components/ui/alert'; import { useLoginActions } from '@/hooks/useLoginActions'; +import { cn } from '@/lib/utils'; interface LoginDialogProps { isOpen: boolean; @@ -22,54 +18,124 @@ interface LoginDialogProps { onSignup?: () => void; } +const validateNsec = (nsec: string) => { + return /^nsec1[a-zA-Z0-9]{58}$/.test(nsec); +}; + +const validateBunkerUri = (uri: string) => { + return uri.startsWith('bunker://'); +}; + const LoginDialog: React.FC = ({ isOpen, onClose, onLogin, onSignup }) => { const [isLoading, setIsLoading] = useState(false); + const [isFileLoading, setIsFileLoading] = useState(false); const [nsec, setNsec] = useState(''); const [bunkerUri, setBunkerUri] = useState(''); + const [errors, setErrors] = useState<{ + nsec?: string; + bunker?: string; + file?: string; + extension?: string; + }>({}); const fileInputRef = useRef(null); const login = useLoginActions(); - const handleExtensionLogin = () => { + // Reset all state when dialog opens/closes + useEffect(() => { + if (isOpen) { + // Reset state when dialog opens + setIsLoading(false); + setIsFileLoading(false); + setNsec(''); + setBunkerUri(''); + setErrors({}); + // Reset file input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }, [isOpen]); + + const handleExtensionLogin = async () => { setIsLoading(true); + setErrors(prev => ({ ...prev, extension: undefined })); + try { if (!('nostr' in window)) { throw new Error('Nostr extension not found. Please install a NIP-07 extension.'); } - login.extension(); + await login.extension(); onLogin(); onClose(); - } catch (error) { + } catch (e: unknown) { + const error = e as Error; + console.error('Bunker login failed:', error); + console.error('Nsec login failed:', error); console.error('Extension login failed:', error); + setErrors(prev => ({ + ...prev, + extension: error instanceof Error ? error.message : 'Extension login failed' + })); } finally { setIsLoading(false); } }; + const executeLogin = (key: string) => { + setIsLoading(true); + setErrors({}); + + // Use a timeout to allow the UI to update before the synchronous login call + setTimeout(() => { + try { + login.nsec(key); + onLogin(); + onClose(); + } catch { + setErrors({ nsec: "Failed to login with this key. Please check that it's correct." }); + setIsLoading(false); + } + }, 50); + }; + const handleKeyLogin = () => { - if (!nsec.trim()) return; - setIsLoading(true); - - try { - login.nsec(nsec); - onLogin(); - onClose(); - } catch (error) { - console.error('Nsec login failed:', error); - } finally { - setIsLoading(false); + if (!nsec.trim()) { + setErrors(prev => ({ ...prev, nsec: 'Please enter your secret key' })); + return; } + + if (!validateNsec(nsec)) { + setErrors(prev => ({ ...prev, nsec: 'Invalid secret key format. Must be a valid nsec starting with nsec1.' })); + return; + } + executeLogin(nsec); }; - const handleBunkerLogin = () => { - if (!bunkerUri.trim() || !bunkerUri.startsWith('bunker://')) return; + const handleBunkerLogin = async () => { + if (!bunkerUri.trim()) { + setErrors(prev => ({ ...prev, bunker: 'Please enter a bunker URI' })); + return; + } + + if (!validateBunkerUri(bunkerUri)) { + setErrors(prev => ({ ...prev, bunker: 'Invalid bunker URI format. Must start with bunker://' })); + return; + } + setIsLoading(true); - + setErrors(prev => ({ ...prev, bunker: undefined })); + try { - login.bunker(bunkerUri); + await login.bunker(bunkerUri); onLogin(); onClose(); - } catch (error) { - console.error('Bunker login failed:', error); + // Clear the URI from memory + setBunkerUri(''); + } catch { + setErrors(prev => ({ + ...prev, + bunker: 'Failed to connect to bunker. Please check the URI.' + })); } finally { setIsLoading(false); } @@ -79,10 +145,27 @@ const LoginDialog: React.FC = ({ isOpen, onClose, onLogin, onS const file = e.target.files?.[0]; if (!file) return; + setIsFileLoading(true); + setErrors({}); + const reader = new FileReader(); reader.onload = (event) => { + setIsFileLoading(false); const content = event.target?.result as string; - setNsec(content.trim()); + if (content) { + const trimmedContent = content.trim(); + if (validateNsec(trimmedContent)) { + executeLogin(trimmedContent); + } else { + setErrors({ file: 'File does not contain a valid secret key.' }); + } + } else { + setErrors({ file: 'Could not read file content.' }); + } + }; + reader.onerror = () => { + setIsFileLoading(false); + setErrors({ file: 'Failed to read file.' }); }; reader.readAsText(file); }; @@ -94,58 +177,139 @@ const LoginDialog: React.FC = ({ isOpen, onClose, onLogin, onS } }; + const defaultTab = 'nostr' in window ? 'extension' : 'key'; + return ( - - - Log in - - Access your account securely with your preferred method - + + + + + Sign up or log in to continue + +
+ {/* Prominent Sign Up Section */} +
+
+
+ + + New to Nostr? + +
+

+ Create a new account to get started. It's free and open. +

+ +
+
-
- - - Extension - Nsec - Bunker + {/* Divider */} +
+
+
+
+
+ + Or log in + +
+
+ + {/* Login Methods */} + + + + + Extension + + + + Key + + + + Bunker + - - + + {errors.extension && ( + + + {errors.extension} + + )}

Login with one click using the browser extension

- +
+ +
-
+ + + +
+
+
+
+
+ + or + +
-

Or upload a key file

= ({ isOpen, onClose, onLogin, onS /> + {errors.file && ( +

{errors.file}

+ )}
- -
- +
- +
+ +
- -
-

- Don't have an account?{' '} - -

-
- ); -}; + ); + }; export default LoginDialog; diff --git a/src/components/auth/SignupDialog.tsx b/src/components/auth/SignupDialog.tsx index 0cf73ea..40315c6 100644 --- a/src/components/auth/SignupDialog.tsx +++ b/src/components/auth/SignupDialog.tsx @@ -1,163 +1,779 @@ // 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 } from 'react'; -import { Download, Key } from 'lucide-react'; -import { Button } from '@/components/ui/button.tsx'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog.tsx'; -import { toast } from '@/hooks/useToast.ts'; +import React, { useState, useEffect, useRef } from 'react'; +import { Download, Key, UserPlus, FileText, Shield, User, Sparkles, LogIn, Lock, CheckCircle, Copy, Upload, Globe, FileSignature, Wand2 } 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, DialogDescription } 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 { isOpen: boolean; onClose: () => void; + onComplete?: () => void; } -const SignupDialog: React.FC = ({ isOpen, onClose }) => { - const [step, setStep] = useState<'generate' | 'download' | 'done'>('generate'); +const sanitizeFilename = (filename: string) => { + return filename.replace(/[^a-z0-9_.-]/gi, '_'); +} + +const SignupDialog: React.FC = ({ isOpen, onClose, onComplete }) => { + const [step, setStep] = useState<'welcome' | 'generate' | 'download' | 'profile' | 'done'>('welcome'); const [isLoading, setIsLoading] = useState(false); const [nsec, setNsec] = useState(''); + const [showSparkles, setShowSparkles] = useState(false); + 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(null); // Generate a proper nsec key using nostr-tools const generateKey = () => { setIsLoading(true); - - try { - // Generate a new secret key - const sk = generateSecretKey(); - - // Convert to nsec format - setNsec(nip19.nsecEncode(sk)); - setStep('download'); - } catch (error) { - console.error('Failed to generate key:', error); - toast({ - title: 'Error', - description: 'Failed to generate key. Please try again.', - variant: 'destructive', - }); - } finally { - setIsLoading(false); - } + setShowSparkles(true); + + // Add a dramatic pause for the key generation effect + setTimeout(() => { + try { + // Generate a new secret key + const sk = generateSecretKey(); + + // Convert to nsec format + setNsec(nip19.nsecEncode(sk)); + setStep('download'); + + toast({ + title: 'Your Secret Key is Ready!', + description: 'A new secret key has been generated for you.', + }); + } catch { + toast({ + title: 'Error', + description: 'Failed to generate key. Please try again.', + variant: 'destructive', + }); + } finally { + setIsLoading(false); + setShowSparkles(false); + } + }, 2000); }; const downloadKey = () => { - // Create a blob with the key text - const blob = new Blob([nsec], { type: 'text/plain' }); - const url = globalThis.URL.createObjectURL(blob); + 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); - // Create a temporary link element and trigger download - const a = document.createElement('a'); - a.href = url; - a.download = 'nsec.txt'; - document.body.appendChild(a); - a.click(); + // Sanitize filename + const filename = sanitizeFilename('secret-key.txt'); - // Clean up - globalThis.URL.revokeObjectURL(url); - document.body.removeChild(a); + // 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 copyKey = () => { + navigator.clipboard.writeText(nsec); + setKeySecured('copied'); toast({ - title: 'Key downloaded', - description: 'Your key has been downloaded. Keep it safe!', + title: 'Copied to clipboard!', + description: 'Key copied to clipboard.', }); }; - const finishSignup = () => { - login.nsec(nsec); - - setStep('done'); - onClose(); - - toast({ - title: 'Account created', - description: 'You are now logged in.', - }); + 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) => { + 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 === 'welcome') return ( + + + Create Your Account + + ); + if (step === 'generate') return ( + + + Generating Your Key + + ); + if (step === 'download') return ( + + + Secret Key + + ); + if (step === 'profile') return ( + + + Create Your Profile + + ); + return ( + + + Welcome! + + ); + }; + + const getDescription = () => { + if (step === 'welcome') return 'Ready to join the Nostr network?'; + if (step === 'generate') return 'Creating your secret key to access Nostr.'; + + if (step === 'profile') return 'Tell others about yourself.'; + return 'Your account is ready!'; + }; + + // Reset state when dialog opens + useEffect(() => { + if (isOpen) { + setStep('welcome'); + setIsLoading(false); + setNsec(''); + setShowSparkles(false); + setKeySecured('none'); + setProfileData({ name: '', about: '', picture: '' }); + } + }, [isOpen]); + + // Add sparkle animation effect + useEffect(() => { + if (showSparkles) { + const interval = setInterval(() => { + // This will trigger re-renders for sparkle animation + }, 100); + return () => clearInterval(interval); + } + }, [showSparkles]); + return ( - - - - {step === 'generate' && 'Create Your Account'} - {step === 'download' && 'Download Your Key'} - {step === 'done' && 'Setting Up Your Account'} + + + + {getTitle()} - - {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'} + + {getDescription()} +
+ {/* Welcome Step - New engaging introduction */} + {step === 'welcome' && ( +
+ {/* Hero illustration */} +
+
+
+ + +
+ +
+ + +
+
-
- {step === 'generate' && ( -
-
- + {/* Benefits */} +
+
+ + Decentralized and censorship-resistant +
+
+ + You are in control of your data +
+
+ + Join a global network +
+
+
+ +
+

+ Join the Nostr network and take control of your social media experience. + Your journey begins by generating a secret key. +

+ + + +

+ Free forever • Decentralized • Your data, your control +

-

- We'll generate a secure key for your account. You'll need this key to log in later. -

-
)} + {/* Generate Step - Enhanced with animations */} + {step === 'generate' && ( +
+
+ {/* Animated background elements */} + {showSparkles && ( +
+ {[...Array(12)].map((_, i) => ( + + ))} +
+ )} + +
+ {isLoading ? ( +
+
+ +
+
+
+
+
+

+ + Generating your secret key... +

+

+ Creating your secure key +

+
+
+ ) : ( +
+ +
+

+ Ready to generate your secret key? +

+

+ This key will be your password to access applications within the Nostr network. +

+ +
+
+ )} +
+
+ + {!isLoading && ( + + )} +
+ )} + + {/* Download Step - Whimsical and magical */} {step === 'download' && ( -
-
- {nsec} +
+ {/* Key reveal */} +
+ {/* Sparkles */} +
+ + + + +
+ +
+
+
+ +
+
+ +
+
+
+ +
+

+ Your secret key has been generated! +

+ + {/* Warning */} +
+
+
+ + + Important Warning + +
+

+ This key is your primary and only means of accessing your account. Store it safely and securely. +

+
+
+
-
-

Important:

-
    -
  • This is your only way to access your account
  • -
  • Store it somewhere safe
  • -
  • Never share this key with anyone
  • -
+ {/* Key vault */} + + + {/* Security options */} +
+ + +
+ {/* Download Option */} + + + + + + + {/* Copy Option */} + + + + + +
+ + {/* Continue button */} + +
+
+ )} + + {/* Profile Step - Optional profile setup */} + {step === 'profile' && ( +
+ {/* Profile setup illustration */} +
+ {/* Sparkles */} +
+ + + +
+ +
+
+
+ +
+
+ +
+
+
+ +
+

+ 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} + /> +
+ +
+ +