mirror of
https://gitlab.com/soapbox-pub/mkstack.git
synced 2025-08-26 20:49:22 +00:00
Merge branch 'signupTemplate' into 'main'
Create expanded signup template See merge request soapbox-pub/mkstack!7
This commit is contained in:
commit
58bd5b1a4e
28
CONTEXT.md
28
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)
|
||||
|
||||
|
@ -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 ? (
|
||||
<AccountSwitcher onAddAccountClick={() => setLoginDialogOpen(true)} />
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => setLoginDialogOpen(true)}
|
||||
className='flex items-center gap-2 px-4 py-2 rounded-full bg-primary text-primary-foreground w-full font-medium transition-all hover:bg-primary/90 animate-scale-in'
|
||||
>
|
||||
<User className='w-4 h-4' />
|
||||
<span className='truncate'>Log in</span>
|
||||
</Button>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Button
|
||||
onClick={() => setLoginDialogOpen(true)}
|
||||
className='flex items-center gap-2 px-4 py-2 rounded-full bg-primary text-primary-foreground w-full font-medium transition-all hover:bg-primary/90 animate-scale-in'
|
||||
>
|
||||
<User className='w-4 h-4' />
|
||||
<span className='truncate'>Log in</span>
|
||||
</Button><Button
|
||||
onClick={() => setSignupDialogOpen(true)}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-full font-medium transition-all"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
<span>Sign Up</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LoginDialog
|
||||
isOpen={loginDialogOpen}
|
||||
onClose={() => setLoginDialogOpen(false)}
|
||||
isOpen={loginDialogOpen}
|
||||
onClose={() => setLoginDialogOpen(false)}
|
||||
onLogin={handleLogin}
|
||||
onSignup={() => setSignupDialogOpen(true)}
|
||||
/>
|
||||
|
@ -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<LoginDialogProps> = ({ 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<HTMLInputElement>(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<LoginDialogProps> = ({ 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<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
|
||||
}
|
||||
};
|
||||
|
||||
const defaultTab = 'nostr' in window ? 'extension' : 'key';
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className='sm:max-w-md p-0 overflow-hidden rounded-2xl'>
|
||||
<DialogHeader className='px-6 pt-6 pb-0 relative'>
|
||||
<DialogTitle className='text-xl font-semibold text-center'>Log in</DialogTitle>
|
||||
<DialogDescription className='text-center text-muted-foreground mt-2'>
|
||||
Access your account securely with your preferred method
|
||||
</DialogDescription>
|
||||
<DialogContent
|
||||
className={cn("max-w-[95vw] sm:max-w-md max-h-[90vh] max-h-[90dvh] p-0 overflow-hidden rounded-2xl overflow-y-scroll")}
|
||||
>
|
||||
<DialogHeader className={cn('px-6 pt-6 pb-1 relative')}>
|
||||
|
||||
<DialogDescription className="text-center">
|
||||
Sign up or log in to continue
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='px-6 pt-2 pb-4 space-y-4 overflow-y-auto flex-1'>
|
||||
{/* Prominent Sign Up Section */}
|
||||
<div className='relative p-4 rounded-2xl bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-blue-950/50 dark:to-indigo-950/50 border border-blue-200 dark:border-blue-800 overflow-hidden'>
|
||||
<div className='relative z-10 text-center space-y-3'>
|
||||
<div className='flex justify-center items-center gap-2 mb-2'>
|
||||
<Sparkles className='w-5 h-5 text-blue-600' />
|
||||
<span className='font-semibold text-blue-800 dark:text-blue-200'>
|
||||
New to Nostr?
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-sm text-blue-700 dark:text-blue-300'>
|
||||
Create a new account to get started. It's free and open.
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleSignupClick}
|
||||
className='w-full rounded-full py-3 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 border-0'
|
||||
>
|
||||
<UserPlus className='w-4 h-4 mr-2' />
|
||||
<span>Sign Up</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='px-6 py-8 space-y-6'>
|
||||
<Tabs defaultValue={'nostr' in window ? 'extension' : 'key'} className='w-full'>
|
||||
<TabsList className='grid grid-cols-3 mb-6'>
|
||||
<TabsTrigger value='extension'>Extension</TabsTrigger>
|
||||
<TabsTrigger value='key'>Nsec</TabsTrigger>
|
||||
<TabsTrigger value='bunker'>Bunker</TabsTrigger>
|
||||
{/* Divider */}
|
||||
<div className='relative'>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<div className='w-full border-t border-gray-300 dark:border-gray-600'></div>
|
||||
</div>
|
||||
<div className='relative flex justify-center text-sm'>
|
||||
<span className='px-3 bg-background text-muted-foreground'>
|
||||
<span>Or log in</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login Methods */}
|
||||
<Tabs defaultValue={defaultTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 bg-muted/80 rounded-lg mb-4">
|
||||
<TabsTrigger value="extension" className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
<span>Extension</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="key" className="flex items-center gap-2">
|
||||
<KeyRound className="w-4 h-4" />
|
||||
<span>Key</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="bunker" className="flex items-center gap-2">
|
||||
<Cloud className="w-4 h-4" />
|
||||
<span>Bunker</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='extension' className='space-y-4'>
|
||||
<TabsContent value='extension' className='space-y-3 bg-muted'>
|
||||
{errors.extension && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>{errors.extension}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className='text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800'>
|
||||
<Shield className='w-12 h-12 mx-auto mb-3 text-primary' />
|
||||
<p className='text-sm text-gray-600 dark:text-gray-300 mb-4'>
|
||||
Login with one click using the browser extension
|
||||
</p>
|
||||
<Button
|
||||
className='w-full rounded-full py-6'
|
||||
onClick={handleExtensionLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Logging in...' : 'Login with Extension'}
|
||||
</Button>
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
className='w-full rounded-full py-4'
|
||||
onClick={handleExtensionLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Logging in...' : 'Login with Extension'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='key' className='space-y-4'>
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<label htmlFor='nsec' className='text-sm font-medium text-gray-700 dark:text-gray-400'>
|
||||
Enter your nsec
|
||||
<label htmlFor='nsec' className='text-sm font-medium'>
|
||||
Secret Key (nsec)
|
||||
</label>
|
||||
<Input
|
||||
type='password'
|
||||
id='nsec'
|
||||
type="password"
|
||||
value={nsec}
|
||||
onChange={(e) => setNsec(e.target.value)}
|
||||
className='rounded-lg border-gray-300 dark:border-gray-700 focus-visible:ring-primary'
|
||||
onChange={(e) => {
|
||||
setNsec(e.target.value);
|
||||
if (errors.nsec) setErrors(prev => ({ ...prev, nsec: undefined }));
|
||||
}}
|
||||
className={`rounded-lg ${
|
||||
errors.nsec ? 'border-red-500 focus-visible:ring-red-500' : ''
|
||||
}`}
|
||||
placeholder='nsec1...'
|
||||
autoComplete="off"
|
||||
/>
|
||||
{errors.nsec && (
|
||||
<p className="text-sm text-red-500">{errors.nsec}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className='w-full rounded-full py-3'
|
||||
onClick={handleKeyLogin}
|
||||
disabled={isLoading || !nsec.trim()}
|
||||
>
|
||||
{isLoading ? 'Verifying...' : 'Log In'}
|
||||
</Button>
|
||||
|
||||
<div className='relative'>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<div className='w-full border-t border-muted'></div>
|
||||
</div>
|
||||
<div className='relative flex justify-center text-xs'>
|
||||
<span className='px-2 bg-background text-muted-foreground'>
|
||||
or
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='text-center'>
|
||||
<p className='text-sm mb-2 text-gray-600 dark:text-gray-400'>Or upload a key file</p>
|
||||
<input
|
||||
type='file'
|
||||
accept='.txt'
|
||||
@ -155,25 +319,21 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
|
||||
/>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700'
|
||||
className='w-full'
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isLoading || isFileLoading}
|
||||
>
|
||||
<Upload className='w-4 h-4 mr-2' />
|
||||
Upload Nsec File
|
||||
{isFileLoading ? 'Reading File...' : 'Upload Your Key File'}
|
||||
</Button>
|
||||
{errors.file && (
|
||||
<p className="text-sm text-red-500 mt-2">{errors.file}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className='w-full rounded-full py-6 mt-4'
|
||||
onClick={handleKeyLogin}
|
||||
disabled={isLoading || !nsec.trim()}
|
||||
>
|
||||
{isLoading ? 'Verifying...' : 'Login with Nsec'}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='bunker' className='space-y-4'>
|
||||
<TabsContent value='bunker' className='space-y-3 bg-muted'>
|
||||
<div className='space-y-2'>
|
||||
<label htmlFor='bunkerUri' className='text-sm font-medium text-gray-700 dark:text-gray-400'>
|
||||
Bunker URI
|
||||
@ -181,40 +341,36 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
|
||||
<Input
|
||||
id='bunkerUri'
|
||||
value={bunkerUri}
|
||||
onChange={(e) => setBunkerUri(e.target.value)}
|
||||
className='rounded-lg border-gray-300 dark:border-gray-700 focus-visible:ring-primary'
|
||||
onChange={(e) => {
|
||||
setBunkerUri(e.target.value);
|
||||
if (errors.bunker) setErrors(prev => ({ ...prev, bunker: undefined }));
|
||||
}}
|
||||
className={`rounded-lg border-gray-300 dark:border-gray-700 focus-visible:ring-primary ${
|
||||
errors.bunker ? 'border-red-500' : ''
|
||||
}`}
|
||||
placeholder='bunker://'
|
||||
autoComplete="off"
|
||||
/>
|
||||
{bunkerUri && !bunkerUri.startsWith('bunker://') && (
|
||||
<p className='text-red-500 text-xs'>URI must start with bunker://</p>
|
||||
{errors.bunker && (
|
||||
<p className="text-sm text-red-500">{errors.bunker}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className='w-full rounded-full py-6'
|
||||
onClick={handleBunkerLogin}
|
||||
disabled={isLoading || !bunkerUri.trim() || !bunkerUri.startsWith('bunker://')}
|
||||
>
|
||||
{isLoading ? 'Connecting...' : 'Login with Bunker'}
|
||||
</Button>
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
className='w-full rounded-full py-4'
|
||||
onClick={handleBunkerLogin}
|
||||
disabled={isLoading || !bunkerUri.trim()}
|
||||
>
|
||||
{isLoading ? 'Connecting...' : 'Login with Bunker'}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className='text-center text-sm'>
|
||||
<p className='text-gray-600 dark:text-gray-400'>
|
||||
Don't have an account?{' '}
|
||||
<button
|
||||
onClick={handleSignupClick}
|
||||
className='text-primary hover:underline font-medium'
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginDialog;
|
||||
|
@ -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<SignupDialogProps> = ({ 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<SignupDialogProps> = ({ 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<HTMLInputElement>(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<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 === 'welcome') return (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<UserPlus className="w-5 h-5 text-primary" />
|
||||
Create Your Account
|
||||
</span>
|
||||
);
|
||||
if (step === 'generate') return (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Wand2 className="w-5 h-5 text-primary" />
|
||||
Generating Your Key
|
||||
</span>
|
||||
);
|
||||
if (step === 'download') return (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Lock className="w-5 h-5 text-primary" />
|
||||
Secret Key
|
||||
</span>
|
||||
);
|
||||
if (step === 'profile') return (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<FileSignature className="w-5 h-5 text-primary" />
|
||||
Create Your Profile
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<User className="w-5 h-5 text-primary" />
|
||||
Welcome!
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className='sm:max-w-md p-0 overflow-hidden rounded-2xl'>
|
||||
<DialogHeader className='px-6 pt-6 pb-0 relative'>
|
||||
<DialogTitle className='text-xl font-semibold text-center'>
|
||||
{step === 'generate' && 'Create Your Account'}
|
||||
{step === 'download' && 'Download Your Key'}
|
||||
{step === 'done' && 'Setting Up Your Account'}
|
||||
<DialogContent
|
||||
className={cn("max-w-[95vw] sm:max-w-md max-h-[90vh] max-h-[90dvh] p-0 overflow-hidden rounded-2xl flex flex-col")}
|
||||
>
|
||||
<DialogHeader className={cn('px-6 pt-6 pb-1 relative flex-shrink-0')}>
|
||||
<DialogTitle className={cn('font-semibold text-center text-lg')}>
|
||||
{getTitle()}
|
||||
</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 className={cn(`text-muted-foreground text-center ${step === 'download' && 'hidden'}`)}>
|
||||
{getDescription()}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='px-6 pt-2 pb-4 space-y-4 overflow-y-scroll flex-1'>
|
||||
{/* Welcome Step - New engaging introduction */}
|
||||
{step === 'welcome' && (
|
||||
<div className='text-center space-y-4'>
|
||||
{/* Hero illustration */}
|
||||
<div className='relative p-6 rounded-2xl bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-blue-950/50 dark:to-indigo-950/50'>
|
||||
<div className='flex justify-center items-center space-x-4 mb-3'>
|
||||
<div className='relative'>
|
||||
<UserPlus className='w-12 h-12 text-blue-600' />
|
||||
<Sparkles className='w-4 h-4 text-yellow-500 absolute -top-1 -right-1 animate-pulse' />
|
||||
</div>
|
||||
<Globe className='w-16 h-16 text-blue-700 animate-spin-slow' />
|
||||
<div className='relative'>
|
||||
<FileText className='w-12 h-12 text-blue-600' />
|
||||
<Sparkles className='w-4 h-4 text-yellow-500 absolute -top-1 -left-1 animate-pulse' style={{animationDelay: '0.3s'}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='px-6 py-8 space-y-6'>
|
||||
{step === 'generate' && (
|
||||
<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'>
|
||||
<Key className='w-16 h-16 text-primary' />
|
||||
{/* Benefits */}
|
||||
<div className='grid grid-cols-1 gap-2 text-sm'>
|
||||
<div className='flex items-center justify-center gap-2 text-blue-700 dark:text-blue-300'>
|
||||
<Shield className='w-4 h-4' />
|
||||
Decentralized and censorship-resistant
|
||||
</div>
|
||||
<div className='flex items-center justify-center gap-2 text-blue-700 dark:text-blue-300'>
|
||||
<User className='w-4 h-4' />
|
||||
You are in control of your data
|
||||
</div>
|
||||
<div className='flex items-center justify-center gap-2 text-blue-700 dark:text-blue-300'>
|
||||
<Globe className='w-4 h-4' />
|
||||
Join a global network
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<p className='text-muted-foreground px-5'>
|
||||
Join the Nostr network and take control of your social media experience.
|
||||
Your journey begins by generating a secret key.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
className='w-full rounded-full py-6 text-lg 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'
|
||||
onClick={() => setStep('generate')}
|
||||
>
|
||||
<LogIn className='w-5 h-5 mr-2' />
|
||||
Get Started
|
||||
</Button>
|
||||
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
Free forever • Decentralized • Your data, your control
|
||||
</p>
|
||||
</div>
|
||||
<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.
|
||||
</p>
|
||||
<Button
|
||||
className='w-full rounded-full py-6'
|
||||
onClick={generateKey}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Generating key...' : 'Generate my key'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generate Step - Enhanced with animations */}
|
||||
{step === 'generate' && (
|
||||
<div className='text-center space-y-4'>
|
||||
<div className='relative p-6 rounded-2xl bg-gradient-to-br from-blue-50 to-purple-100 dark:from-blue-950/50 dark:to-purple-950/50 overflow-hidden'>
|
||||
{/* Animated background elements */}
|
||||
{showSparkles && (
|
||||
<div className='absolute inset-0'>
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<Sparkles
|
||||
key={i}
|
||||
className={`absolute w-4 h-4 text-yellow-400 animate-ping`}
|
||||
style={{
|
||||
left: `${Math.random() * 80 + 10}%`,
|
||||
top: `${Math.random() * 80 + 10}%`,
|
||||
animationDelay: `${Math.random() * 2}s`
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='relative z-10'>
|
||||
{isLoading ? (
|
||||
<div className='space-y-3'>
|
||||
<div className='relative'>
|
||||
<Key className='w-20 h-20 text-primary mx-auto animate-pulse' />
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
<div className='w-24 h-24 border-4 border-yellow-400 border-t-transparent rounded-full animate-spin'></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<p className='text-lg font-semibold text-primary flex items-center justify-center gap-2'>
|
||||
<Sparkles className='w-5 h-5' />
|
||||
Generating your secret key...
|
||||
</p>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
Creating your secure key
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
<Key className='w-20 h-20 text-primary mx-auto' />
|
||||
<div className='space-y-2'>
|
||||
<p className='text-lg font-semibold'>
|
||||
Ready to generate your secret key?
|
||||
</p>
|
||||
<p className='text-sm text-muted-foreground px-5'>
|
||||
This key will be your password to access applications within the Nostr network.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isLoading && (
|
||||
<Button
|
||||
className='w-full rounded-full py-6 text-lg font-semibold bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700 transform transition-all duration-200 hover:scale-105 shadow-lg'
|
||||
onClick={generateKey}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Sparkles className='w-5 h-5 mr-2' />
|
||||
Generate My Secret Key
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download Step - Whimsical and magical */}
|
||||
{step === 'download' && (
|
||||
<div className='space-y-6'>
|
||||
<div className='p-4 rounded-lg border bg-gray-50 dark:bg-gray-800 overflow-auto'>
|
||||
<code className='text-xs break-all'>{nsec}</code>
|
||||
<div className='text-center space-y-4'>
|
||||
{/* Key reveal */}
|
||||
<div className='relative p-6 rounded-2xl bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-blue-950/50 dark:to-indigo-950/50 overflow-hidden'>
|
||||
{/* Sparkles */}
|
||||
<div className='absolute inset-0 pointer-events-none'>
|
||||
<Sparkles className='absolute top-3 left-4 w-3 h-3 text-yellow-400 animate-pulse' style={{animationDelay: '0s'}} />
|
||||
<Sparkles className='absolute top-6 right-6 w-3 h-3 text-yellow-500 animate-pulse' style={{animationDelay: '0.5s'}} />
|
||||
<Sparkles className='absolute bottom-4 left-6 w-3 h-3 text-yellow-400 animate-pulse' style={{animationDelay: '1s'}} />
|
||||
<Sparkles className='absolute bottom-3 right-4 w-3 h-3 text-yellow-500 animate-pulse' style={{animationDelay: '1.5s'}} />
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 flex justify-center items-center mb-3'>
|
||||
<div className='relative'>
|
||||
<div className='w-16 h-16 bg-gradient-to-br from-blue-200 to-indigo-300 rounded-full flex items-center justify-center shadow-lg animate-pulse'>
|
||||
<Key className='w-8 h-8 text-indigo-800' />
|
||||
</div>
|
||||
<div className='absolute -top-1 -right-1 w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center animate-bounce'>
|
||||
<Sparkles className='w-3 h-3 text-white' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 space-y-2'>
|
||||
<p className='text-base font-semibold'>
|
||||
Your secret key has been generated!
|
||||
</p>
|
||||
|
||||
{/* Warning */}
|
||||
<div className='relative mx-auto max-w-sm'>
|
||||
<div className='p-3 bg-gradient-to-r from-amber-100 via-yellow-50 to-amber-100 dark:from-amber-950/40 dark:via-yellow-950/20 dark:to-amber-950/40 rounded-lg border-2 border-amber-300 dark:border-amber-700 shadow-md'>
|
||||
<div className='flex items-center gap-2 mb-1'>
|
||||
<FileText className='w-3 h-3 text-amber-700' />
|
||||
<span className='text-xs font-bold text-amber-800 dark:text-amber-200'>
|
||||
Important Warning
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-xs text-red-700 dark:text-amber-300 italic'>
|
||||
This key is your primary and only means of accessing your account. Store it safely and securely.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='text-sm text-gray-600 dark:text-gray-300 space-y-2'>
|
||||
<p className='font-medium text-red-500'>Important:</p>
|
||||
<ul className='list-disc pl-5 space-y-1'>
|
||||
<li>This is your only way to access your account</li>
|
||||
<li>Store it somewhere safe</li>
|
||||
<li>Never share this key with anyone</li>
|
||||
</ul>
|
||||
{/* Key vault */}
|
||||
|
||||
|
||||
{/* Security options */}
|
||||
<div className='space-y-3'>
|
||||
|
||||
|
||||
<div className='grid grid-cols-1 gap-2'>
|
||||
{/* Download Option */}
|
||||
<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 secret-key.txt file
|
||||
</div>
|
||||
</div>
|
||||
{keySecured === 'downloaded' && (
|
||||
<div className='text-xs font-medium text-green-600'>
|
||||
✓ Downloaded
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Copy Option */}
|
||||
<Card className={`cursor-pointer transition-all duration-200 ${
|
||||
keySecured === 'copied'
|
||||
? '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={copyKey}
|
||||
>
|
||||
<div className='flex items-center gap-3 w-full'>
|
||||
<div className={`p-1.5 rounded-lg ${
|
||||
keySecured === 'copied'
|
||||
? 'bg-green-100 dark:bg-green-900'
|
||||
: 'bg-primary/10'
|
||||
}`}>
|
||||
{keySecured === 'copied' ? (
|
||||
<CheckCircle className='w-4 h-4 text-green-600' />
|
||||
) : (
|
||||
<Copy className='w-4 h-4 text-primary' />
|
||||
)}
|
||||
</div>
|
||||
<div className='flex-1 text-left'>
|
||||
<div className='font-medium text-sm'>
|
||||
Copy to Clipboard
|
||||
</div>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
Save to password manager
|
||||
</div>
|
||||
<div className='text-[.7rem] text-muted-foreground'>
|
||||
{nsec.slice(0,16)}...
|
||||
</div>
|
||||
</div>
|
||||
{keySecured === 'copied' && (
|
||||
<div className='text-xs font-medium text-green-600'>
|
||||
✓ Copied
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Continue button */}
|
||||
<Button
|
||||
className={`w-full rounded-full py-4 text-base font-semibold transform transition-all duration-200 shadow-lg ${
|
||||
keySecured !== 'none'
|
||||
? 'bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 dark:from-blue-950/50 dark:to-purple-950/50 hover:scale-105'
|
||||
: 'bg-gradient-to-r from-blue-600/60 to-indigo-600/60 text-muted cursor-not-allowed'
|
||||
}`}
|
||||
onClick={finishKeySetup}
|
||||
disabled={keySecured === 'none'}
|
||||
>
|
||||
<LogIn className='w-4 h-4 mr-2 flex-shrink-0' />
|
||||
<span className="text-center leading-tight">
|
||||
{keySecured === 'none' ? (
|
||||
<>
|
||||
Please secure your key first
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="hidden sm:inline">My Key is Safe - Continue</span>
|
||||
<span className="sm:hidden">Key Secured - Continue</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile Step - Optional profile setup */}
|
||||
{step === 'profile' && (
|
||||
<div className='text-center space-y-4'>
|
||||
{/* Profile setup illustration */}
|
||||
<div className='relative p-6 rounded-2xl bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-blue-950/50 dark:to-indigo-950/50 overflow-hidden'>
|
||||
{/* Sparkles */}
|
||||
<div className='absolute inset-0 pointer-events-none'>
|
||||
<Sparkles className='absolute top-3 left-4 w-3 h-3 text-yellow-400 animate-pulse' style={{animationDelay: '0s'}} />
|
||||
<Sparkles className='absolute top-6 right-6 w-3 h-3 text-yellow-500 animate-pulse' style={{animationDelay: '0.5s'}} />
|
||||
<Sparkles className='absolute bottom-4 left-6 w-3 h-3 text-yellow-400 animate-pulse' style={{animationDelay: '1s'}} />
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 flex justify-center items-center mb-3'>
|
||||
<div className='relative'>
|
||||
<div className='w-16 h-16 bg-gradient-to-br from-blue-200 to-indigo-300 rounded-full flex items-center justify-center shadow-lg'>
|
||||
<User className='w-8 h-8 text-blue-800' />
|
||||
</div>
|
||||
<div className='absolute -top-1 -right-1 w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center animate-bounce'>
|
||||
<Sparkles className='w-3 h-3 text-white' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='relative z-10 space-y-2'>
|
||||
<p className='text-base 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>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col space-y-3'>
|
||||
{/* 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'
|
||||
onClick={downloadKey}
|
||||
className='w-full rounded-full py-3 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
onClick={() => finishSignup(true)}
|
||||
disabled={isPublishing || isUploading}
|
||||
>
|
||||
<Download className='w-4 h-4 mr-2' />
|
||||
Download Key
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className='w-full rounded-full py-6'
|
||||
onClick={finishSignup}
|
||||
>
|
||||
I've saved my key, continue
|
||||
{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>
|
||||
)}
|
||||
|
||||
{step === 'done' && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
Loading…
x
Reference in New Issue
Block a user