Merge branch 'main' of gitlab.com:soapbox-pub/mkstack into zapSupport

This commit is contained in:
Chad Curtis 2025-07-17 22:38:39 +00:00
commit 612aacb00b
11 changed files with 1346 additions and 263 deletions

View File

@ -495,25 +495,89 @@ 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.
**Important**: Social applications should include a profile menu button in the main interface (typically in headers/navigation) to provide access to account settings, profile editing, and logout functionality. Don't only show `LoginArea` in logged-out states.
### `npub`, `naddr`, and other Nostr addresses
Nostr defines a set identifiers in NIP-19. Their prefixes:
Nostr defines a set of bech32-encoded identifiers in NIP-19. Their prefixes and purposes:
- `npub`: public keys
- `nsec`: private keys
- `note`: note ids
- `nprofile`: a nostr profile
- `nevent`: a nostr event
- `naddr`: a nostr replaceable event coordinate
- `nrelay`: a nostr relay (deprecated)
- `npub1`: **public keys** - Just the 32-byte public key, no additional metadata
- `nsec1`: **private keys** - Secret keys (should never be displayed publicly)
- `note1`: **event IDs** - Just the 32-byte event ID (hex), no additional metadata
- `nevent1`: **event pointers** - Event ID plus optional relay hints and author pubkey
- `nprofile1`: **profile pointers** - Public key plus optional relay hints and petname
- `naddr1`: **addressable event coordinates** - For parameterized replaceable events (kind 30000-39999)
- `nrelay1`: **relay references** - Relay URLs (deprecated)
NIP-19 identifiers include a prefix, the number "1", then a base32-encoded data string.
#### Key Differences Between Similar Identifiers
**`note1` vs `nevent1`:**
- `note1`: Contains only the event ID (32 bytes) - specifically for kind:1 events (Short Text Notes) as defined in NIP-10
- `nevent1`: Contains event ID plus optional relay hints and author pubkey - for any event kind
- Use `note1` for simple references to text notes and threads
- Use `nevent1` when you need to include relay hints or author context for any event type
**`npub1` vs `nprofile1`:**
- `npub1`: Contains only the public key (32 bytes)
- `nprofile1`: Contains public key plus optional relay hints and petname
- Use `npub1` for simple user references
- Use `nprofile1` when you need to include relay hints or display name context
#### NIP-19 Routing Implementation
**Critical**: NIP-19 identifiers should be handled at the **root level** of URLs (e.g., `/note1...`, `/npub1...`, `/naddr1...`), NOT nested under paths like `/note/note1...` or `/profile/npub1...`.
This project includes a boilerplate `NIP19Page` component that provides the foundation for handling all NIP-19 identifier types at the root level. The component is configured in the routing system and ready for AI agents to populate with specific functionality.
**How it works:**
1. **Root-Level Route**: The route `/:nip19` in `AppRouter.tsx` catches all NIP-19 identifiers
2. **Automatic Decoding**: The `NIP19Page` component automatically decodes the identifier using `nip19.decode()`
3. **Type-Specific Sections**: Different sections are rendered based on the identifier type:
- `npub1`/`nprofile1`: Profile section with placeholder for profile view
- `note1`: Note section with placeholder for kind:1 text note view
- `nevent1`: Event section with placeholder for any event type view
- `naddr1`: Addressable event section with placeholder for articles, marketplace items, etc.
4. **Error Handling**: Invalid, vacant, or unsupported identifiers show 404 NotFound page
5. **Ready for Population**: Each section includes comments indicating where AI agents should implement specific functionality
**Example URLs that work automatically:**
- `/npub1abc123...` - User profile (needs implementation)
- `/note1def456...` - Kind:1 text note (needs implementation)
- `/nevent1ghi789...` - Any event with relay hints (needs implementation)
- `/naddr1jkl012...` - Addressable event (needs implementation)
**Features included:**
- Basic NIP-19 identifier decoding and routing
- Type-specific sections for different identifier types
- Error handling for invalid identifiers
- Responsive container structure
- Comments indicating where to implement specific views
**Error handling:**
- Invalid NIP-19 format → 404 NotFound
- Unsupported identifier types (like `nsec1`) → 404 NotFound
- Empty or missing identifiers → 404 NotFound
To implement NIP-19 routing in your Nostr application:
1. **The NIP19Page boilerplate is already created** - populate sections with specific functionality
2. **The route is already configured** in `AppRouter.tsx`
3. **Error handling is built-in** - all edge cases show appropriate 404 responses
4. **Add specific components** for profile views, event displays, etc. as needed
#### Event Type Distinctions
**`note1` identifiers** are specifically for **kind:1 events** (Short Text Notes) as defined in NIP-10: "Text Notes and Threads". These are the basic social media posts in Nostr.
**`nevent1` identifiers** can reference any event kind and include additional metadata like relay hints and author pubkey. Use `nevent1` when:
- The event is not a kind:1 text note
- You need to include relay hints for better discoverability
- You want to include author context
#### Use in Filters
@ -555,22 +619,20 @@ const events = await nostr.query(
);
```
#### Use in URL Paths
#### Implementation Guidelines
For URL routing, use NIP-19 identifiers as path parameters (e.g., `/:nip19`) to create secure, universal links to Nostr events. Decode the identifier and render the appropriate component based on the type:
- Regular events: Use `/nevent1...` paths
- Replaceable/addressable events: Use `/naddr1...` paths
Always use `naddr` identifiers for addressable events instead of just the `d` tag value, as `naddr` contains the author pubkey needed to create secure filters. This prevents security issues where malicious actors could publish events with the same `d` tag to override content.
```ts
// Secure routing with naddr
const decoded = nip19.decode(params.nip19);
if (decoded.type === 'naddr' && decoded.data.kind === 30024) {
// Render ArticlePage component
}
```
1. **Always decode NIP-19 identifiers** before using them in queries
2. **Use the appropriate identifier type** based on your needs:
- Use `note1` for kind:1 text notes specifically
- Use `nevent1` when including relay hints or for non-kind:1 events
- Use `naddr1` for addressable events (always includes author pubkey for security)
3. **Handle different identifier types** appropriately:
- `npub1`/`nprofile1`: Display user profiles
- `note1`: Display kind:1 text notes specifically
- `nevent1`: Display any event with optional relay context
- `naddr1`: Display addressable events (articles, marketplace items, etc.)
4. **Security considerations**: Always use `naddr1` for addressable events instead of just the `d` tag value, as `naddr1` contains the author pubkey needed to create secure filters
5. **Error handling**: Gracefully handle invalid or unsupported NIP-19 identifiers with 404 responses
### Nostr Edit Profile
@ -927,15 +989,30 @@ When users specify color schemes:
- Implement responsive design with Tailwind breakpoints
- Add hover and focus states for interactive elements
## Writing Tests
## Writing Tests vs Running Tests
**⚠️ CRITICAL FOR AI ASSISTANTS**: **DO NOT WRITE TESTS** unless the user is experiencing a specific problem or explicitly requests tests. Writing unnecessary tests wastes significant time and money. Only create tests when:
There is an important distinction between **writing new tests** and **running existing tests**:
1. **The user is experiencing a bug** that requires testing to diagnose
2. **The user explicitly asks for tests** to be written
3. **Existing functionality is broken** and tests are needed to verify fixes
### Writing Tests (Creating New Test Files)
**Do not write tests** unless the user explicitly requests them in plain language. Writing unnecessary tests wastes significant time and money. Only create tests when:
**Never proactively write tests** for new features or components. Focus on building functionality that works, not on testing it unless specifically requested.
1. **The user explicitly asks for tests** to be written in their message
2. **The user describes a specific bug in plain language** and requests tests to help diagnose it
3. **The user says they are still experiencing a problem** that you have already attempted to solve (tests can help verify the fix)
**Never write tests because:**
- Tool results show test failures (these are not user requests)
- You think tests would be helpful
- New features or components are created
- Existing functionality needs verification
### Running Tests (Executing the Test Suite)
**ALWAYS run the test script** after making any code changes. This is mandatory regardless of whether you wrote new tests or not.
- **You must run the test script** using `js-dev__run_script` tool with the "test" parameter
- **Your task is not complete** until the test script passes without errors
- **This applies to all changes** - bug fixes, new features, refactoring, or any code modifications
- **The test script includes** TypeScript compilation, ESLint checks, and existing test validation
### Test Setup
@ -968,6 +1045,8 @@ describe('MyComponent', () => {
## Testing Your Changes
Whenever you are finished modifying code, you must run the **test** script using the **js-dev__run_script** tool.
**CRITICAL**: Whenever you are finished modifying code, you must run the **test** script using the **js-dev__run_script** tool.
**Your task is not considered finished until this test passes without errors.**
**Your task is not considered finished until this test passes without errors.**
**This requirement applies regardless of whether you wrote new tests or not.** The test script validates the entire codebase, including TypeScript compilation, ESLint rules, and existing test suite.

24
package-lock.json generated
View File

@ -11,8 +11,8 @@
"@fontsource-variable/inter": "^5.2.6",
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^3.9.0",
"@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.3",
"@nostrify/react": "npm:@jsr/nostrify__react@^0.2.7",
"@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.4",
"@nostrify/react": "npm:@jsr/nostrify__react@^0.2.8",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-aspect-ratio": "^1.1.0",
@ -1249,9 +1249,9 @@
}
},
"node_modules/@jsr/nostrify__nostrify": {
"version": "0.46.3",
"resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__nostrify/0.46.3.tgz",
"integrity": "sha512-zJpOrD8bbrJroLRJjESAJZX/ZKFCaGfoz5fSfLP+gIcTiPo8JpzlrFBF6mvaDI/Mdd+1WTBwlCcW9On8rUVH7w==",
"version": "0.46.4",
"resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__nostrify/0.46.4.tgz",
"integrity": "sha512-bYuYkw9+jnHIDEJD6i+w0p8xV1oBLr1RF5rS3qF2yTz1b8i50dXKMO2cLObjalAAO3RYbnib3l85t1ZgWLCzAA==",
"dependencies": {
"@jsr/nostrify__types": "^0.36.0",
"@jsr/scure__base": "^1.2.4",
@ -1359,9 +1359,9 @@
},
"node_modules/@nostrify/nostrify": {
"name": "@jsr/nostrify__nostrify",
"version": "0.46.3",
"resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__nostrify/0.46.3.tgz",
"integrity": "sha512-zJpOrD8bbrJroLRJjESAJZX/ZKFCaGfoz5fSfLP+gIcTiPo8JpzlrFBF6mvaDI/Mdd+1WTBwlCcW9On8rUVH7w==",
"version": "0.46.4",
"resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__nostrify/0.46.4.tgz",
"integrity": "sha512-bYuYkw9+jnHIDEJD6i+w0p8xV1oBLr1RF5rS3qF2yTz1b8i50dXKMO2cLObjalAAO3RYbnib3l85t1ZgWLCzAA==",
"dependencies": {
"@jsr/nostrify__types": "^0.36.0",
"@jsr/scure__base": "^1.2.4",
@ -1374,11 +1374,11 @@
},
"node_modules/@nostrify/react": {
"name": "@jsr/nostrify__react",
"version": "0.2.7",
"resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__react/0.2.7.tgz",
"integrity": "sha512-36fAOOymf34KR2OfE4jXBojbZnPsrIzQDAXzE7dko8/Qj+0s8iKnZoZKg9DVIT2F6Lxhr6SUiTze8xBxuJbf1A==",
"version": "0.2.8",
"resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__react/0.2.8.tgz",
"integrity": "sha512-9sCsMm1uwuGWU+NhgFf6LS/4JzeRXnGOz+ocA5X3/ncvxZhidPOEAU39QyygRLFo/JYG8OC5wlyH8w0Uejq+VQ==",
"dependencies": {
"@jsr/nostrify__nostrify": "^0.46.3",
"@jsr/nostrify__nostrify": "^0.46.4",
"nostr-tools": "^2.13.0",
"react": "^18.0.0"
}

View File

@ -13,8 +13,8 @@
"@fontsource-variable/inter": "^5.2.6",
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^3.9.0",
"@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.3",
"@nostrify/react": "npm:@jsr/nostrify__react@^0.2.7",
"@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.4",
"@nostrify/react": "npm:@jsr/nostrify__react@^0.2.8",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-aspect-ratio": "^1.1.0",

View File

@ -2,6 +2,7 @@ import { BrowserRouter, Route, Routes } from "react-router-dom";
import { ScrollToTop } from "./components/ScrollToTop";
import Index from "./pages/Index";
import { NIP19Page } from "./pages/NIP19Page";
import NotFound from "./pages/NotFound";
export function AppRouter() {
@ -10,6 +11,8 @@ export function AppRouter() {
<ScrollToTop />
<Routes>
<Route path="/" element={<Index />} />
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
<Route path="/:nip19" element={<NIP19Page />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} />
</Routes>

View File

@ -0,0 +1,113 @@
import { Component, ErrorInfo, ReactNode } from 'react';
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
return {
hasError: true,
error,
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Error caught by ErrorBoundary:', error, errorInfo);
this.setState({
error,
errorInfo,
});
}
handleReset = () => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="max-w-md w-full space-y-4">
<div className="text-center">
<h2 className="text-2xl font-bold text-foreground mb-2">
Something went wrong
</h2>
<p className="text-muted-foreground">
An unexpected error occurred. The error has been reported.
</p>
</div>
<div className="bg-muted p-4 rounded-lg">
<details className="text-sm">
<summary className="cursor-pointer font-medium text-foreground">
Error details
</summary>
<div className="mt-2 space-y-2">
<div>
<strong className="text-foreground">Message:</strong>
<p className="text-muted-foreground mt-1">
{this.state.error?.message}
</p>
</div>
{this.state.error?.stack && (
<div>
<strong className="text-foreground">Stack trace:</strong>
<pre className="text-xs text-muted-foreground mt-1 overflow-auto max-h-32">
{this.state.error.stack}
</pre>
</div>
)}
</div>
</details>
</div>
<div className="flex gap-2">
<button
onClick={this.handleReset}
className="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
>
Try again
</button>
<button
onClick={() => window.location.reload()}
className="flex-1 px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90 transition-colors"
>
Reload page
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@ -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)}
/>

View File

@ -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;

View File

@ -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>

View File

@ -3,10 +3,15 @@ import { createRoot } from 'react-dom/client';
// Import polyfills first
import './lib/polyfills.ts';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import App from './App.tsx';
import './index.css';
// FIXME: a custom font should be used. Eg:
// import '@fontsource-variable/<font-name>';
createRoot(document.getElementById("root")!).render(<App />);
createRoot(document.getElementById("root")!).render(
<ErrorBoundary>
<App />
</ErrorBoundary>
);

42
src/pages/NIP19Page.tsx Normal file
View File

@ -0,0 +1,42 @@
import { nip19 } from 'nostr-tools';
import { useParams } from 'react-router-dom';
import NotFound from './NotFound';
export function NIP19Page() {
const { nip19: identifier } = useParams<{ nip19: string }>();
if (!identifier) {
return <NotFound />;
}
let decoded;
try {
decoded = nip19.decode(identifier);
} catch {
return <NotFound />;
}
const { type } = decoded;
switch (type) {
case 'npub':
case 'nprofile':
// AI agent should implement profile view here
return <div>Profile placeholder</div>;
case 'note':
// AI agent should implement note view here
return <div>Note placeholder</div>;
case 'nevent':
// AI agent should implement event view here
return <div>Event placeholder</div>;
case 'naddr':
// AI agent should implement addressable event view here
return <div>Addressable event placeholder</div>;
default:
return <NotFound />;
}
}

View File

@ -0,0 +1,60 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { ErrorBoundary } from '@/components/ErrorBoundary';
// Test component that throws an error
const ThrowError = ({ shouldThrow }: { shouldThrow: boolean }) => {
if (shouldThrow) {
throw new Error('Test error');
}
return <div>No error</div>;
};
describe('ErrorBoundary', () => {
it('renders children when no error occurs', () => {
render(
<ErrorBoundary>
<div>Test content</div>
</ErrorBoundary>
);
expect(screen.getByText('Test content')).toBeInTheDocument();
});
it('catches and displays error when child throws', () => {
// Suppress console.error for this test
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
);
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
expect(screen.getByText('An unexpected error occurred. The error has been reported.')).toBeInTheDocument();
consoleSpy.mockRestore();
});
it('uses custom fallback when provided', () => {
const customFallback = <div>Custom error message</div>;
// Suppress console.error for this test
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary fallback={customFallback}>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
);
expect(screen.getByText('Custom error message')).toBeInTheDocument();
consoleSpy.mockRestore();
});
});