Sync with skate-spots: THUG2 neon theme, remove old AuthDialog and unused lib files

- Replace generic MKStack default CSS with THUG2-inspired neon theme:
  - Neon red primary (#ff0040) and neon lime accent (#ccff00)
  - Pure black dark mode background
  - Custom CSS variables for gradients, sidebar, and animations
  - Leaflet map dark mode overrides
  - Custom scrollbar styling
  - Mobile touch target improvements and iOS input zoom fix
  - Safe area insets for notched devices
  - scrollbar-hide utility class
  - Animation classes: neon-glow, stagger, fadeIn, slideUp, pulse-glow
  - Glass effect and gradient-text utilities
- Remove AuthDialog.tsx (old Sign Up flow replaced by newer LoginArea/SignupDialog)
- Remove appBlossom.ts and appRelays.ts (not used in this project)
- Remove useLoginActions.test.tsx (not present in reference version)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-15 23:00:01 +07:00
parent 540eeead2f
commit 122bdfb102
5 changed files with 341 additions and 1124 deletions

View file

@ -1,918 +0,0 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
Download,
Upload,
Eye,
EyeOff,
Key,
ChevronDown,
ChevronUp,
Loader2,
ExternalLink,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { QRCodeCanvas } from '@/components/ui/qrcode';
import { toast } from '@/hooks/useToast';
import {
useLoginActions,
generateNostrConnectParams,
generateNostrConnectURI,
type NostrConnectParams,
type NostrConnectStatus,
} from '@/hooks/useLoginActions';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
interface AuthDialogProps {
isOpen: boolean;
onClose: () => void;
}
type Step = 'welcome' | 'generate' | 'secure' | 'profile' | 'login' | 'connect';
const validateNsec = (nsec: string) => /^nsec1[a-zA-Z0-9]{58}$/.test(nsec);
const validateBunkerUri = (uri: string) => uri.startsWith('bunker://');
const connectStatusLabel = (status: NostrConnectStatus | null): string => {
switch (status) {
case 'awaiting-connect':
return 'Waiting for signer connection…';
case 'getting-public-key':
return 'Getting public key…';
default:
return '';
}
};
/** Check if running on an actual mobile device (not just a small screen). */
function isMobileDevice(): boolean {
if (typeof navigator === 'undefined') return false;
return /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
}
/** Download an nsec to a text file — same behavior as the previous SignupDialog. */
function downloadNsecFile(nsec: string) {
const decoded = nip19.decode(nsec);
if (decoded.type !== 'nsec') throw new Error('Invalid nsec key');
const pubkey = getPublicKey(decoded.data);
const npub = nip19.npubEncode(pubkey);
const filename = `nostr-${location.hostname.replaceAll(/\./g, '-')}-${npub.slice(5, 9)}.nsec.txt`;
const blob = new Blob([nsec], { type: 'text/plain; charset=utf-8' });
const url = globalThis.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
globalThis.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
const AuthDialog: React.FC<AuthDialogProps> = ({ isOpen, onClose }) => {
const [step, setStep] = useState<Step>('welcome');
// Signup state
const [nsec, setNsec] = useState('');
const [showKey, setShowKey] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [profileData, setProfileData] = useState({ name: '', about: '', picture: '' });
// Login state
const [loginNsec, setLoginNsec] = useState('');
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [loginError, setLoginError] = useState('');
const [showMoreOptions, setShowMoreOptions] = useState(false);
// Nostrconnect / bunker state
const [nostrConnectParams, setNostrConnectParams] = useState<NostrConnectParams | null>(null);
const [nostrConnectUri, setNostrConnectUri] = useState('');
const [connectError, setConnectError] = useState<string | null>(null);
// Progress status for the nostrconnect handshake. `null` means the user
// hasn't kicked off the handshake yet (or they canceled) — we show the QR
// / "Open signer app" button. Once the handshake advances we swap in a
// spinner with a live status line so the user knows something is working.
const [connectStatus, setConnectStatus] = useState<NostrConnectStatus | null>(null);
// Tracks whether the user has explicitly initiated the handshake from the
// mobile UI. The listen subscription itself starts the moment params are
// generated — without this flag we'd flip into the progress view as soon
// as the user enters the Remote Signer step, before they've done anything.
// Desktop doesn't need this: it stays on the QR until the handshake
// advances past `awaiting-connect`.
const [hasOpenedSigner, setHasOpenedSigner] = useState(false);
const [showBunkerInput, setShowBunkerInput] = useState(false);
const [bunkerUri, setBunkerUri] = useState('');
const login = useLoginActions();
// Stable refs so the nostrconnect listening effect below doesn't restart on
// every parent render. Parents typically pass inline arrow functions for
// onClose, and useLoginActions returns a fresh object each render — without
// stable refs, an effect depending on them would tear down the in-flight
// subscription on every render and cause approved logins to be swallowed.
const loginRef = useRef(login);
const onCloseRef = useRef(onClose);
useEffect(() => {
loginRef.current = login;
}, [login]);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { user: currentUser } = useCurrentUser();
const avatarFileInputRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const isMobile = useIsMobile();
const hasExtension = typeof window !== 'undefined' && 'nostr' in window;
// Reset state when the dialog closes.
// This is the "reset state when a prop changes" pattern; the usual
// React-preferred alternative is a `key` prop on the caller, but the
// public API of this component is a simple open/close boolean, so we
// reset here. The multiple setState calls are intentional.
useEffect(() => {
if (!isOpen) {
/* eslint-disable react-hooks/set-state-in-effect */
setStep('welcome');
setNsec('');
setLoginNsec('');
setShowKey(false);
setIsGenerating(false);
setIsLoggingIn(false);
setLoginError('');
setShowMoreOptions(false);
setProfileData({ name: '', about: '', picture: '' });
setNostrConnectParams(null);
setNostrConnectUri('');
setConnectError(null);
setConnectStatus(null);
setHasOpenedSigner(false);
setShowBunkerInput(false);
setBunkerUri('');
/* eslint-enable react-hooks/set-state-in-effect */
abortControllerRef.current?.abort();
abortControllerRef.current = null;
}
}, [isOpen]);
// Generate a nostrconnect session (QR code data).
const generateConnectSession = useCallback(() => {
const relayUrls = login.getRelayUrls();
const params = generateNostrConnectParams(relayUrls);
const uri = generateNostrConnectURI(params, {
callback: isMobileDevice() ? `${window.location.origin}/remoteloginsuccess` : undefined,
});
setNostrConnectParams(params);
setNostrConnectUri(uri);
setConnectError(null);
}, [login]);
// Start listening for a nostrconnect response once params are set.
//
// Deps are intentionally limited to `nostrConnectParams` so that parent
// re-renders (which produce fresh onClose closures and a fresh `login`
// object from useLoginActions) do NOT tear down an in-flight
// subscription. An earlier version used a `cancelled` flag flipped by
// the effect's cleanup, which caused a successful nostrconnect response
// to be silently swallowed after the signer approved — the subscription
// was re-created mid-handshake and the first instance's success branch
// saw `cancelled === true`.
//
// Cancellation is handled explicitly by the `isOpen` effect (on dialog
// close) and by handleConnectRetry() (on user cancel/retry).
useEffect(() => {
if (!nostrConnectParams) return;
const startListening = async () => {
const controller = new AbortController();
abortControllerRef.current = controller;
try {
await loginRef.current.nostrconnect(
nostrConnectParams,
controller.signal,
(status) => {
if (controller.signal.aborted) return;
setConnectStatus(status);
},
);
// If the dialog was explicitly closed (handled by the isOpen
// effect, which aborts the controller), don't try to re-close it.
// Otherwise the user is logged in — close the dialog.
if (controller.signal.aborted) return;
onCloseRef.current();
} catch (error) {
// AbortError means we intentionally aborted (dialog closed or retry)
if (error instanceof Error && error.name === 'AbortError') return;
if (controller.signal.aborted) return;
console.error('Nostrconnect failed:', error);
setConnectStatus(null);
setConnectError(error instanceof Error ? error.message : String(error));
}
};
startListening();
// No cleanup here: we do NOT want a re-render-triggered effect teardown
// to cancel the in-flight subscription.
}, [nostrConnectParams]);
const handleConnectRetry = useCallback(() => {
abortControllerRef.current?.abort();
setNostrConnectParams(null);
setNostrConnectUri('');
setConnectError(null);
setConnectStatus(null);
setHasOpenedSigner(false);
setTimeout(() => generateConnectSession(), 0);
}, [generateConnectSession]);
const handleOpenSignerApp = () => {
if (!nostrConnectUri) return;
// Flip into the progress view *synchronously* before navigating so that
// when the user returns from the signer app, the dialog is already
// showing "Waiting for signer connection…" — not the original button
// they're worried they need to re-tap.
setHasOpenedSigner(true);
window.location.href = nostrConnectUri;
};
const handleBunkerLogin = async () => {
if (!bunkerUri.trim() || !validateBunkerUri(bunkerUri)) return;
setIsLoggingIn(true);
try {
await login.bunker(bunkerUri);
onClose();
} catch {
setConnectError('Failed to connect. Check the bunker URI.');
setIsLoggingIn(false);
}
};
const goToConnect = () => {
setStep('connect');
if (!nostrConnectParams && !connectError) {
generateConnectSession();
}
};
// Signup: generate a key with a brief spinner for feedback.
const generateKey = () => {
setIsGenerating(true);
setTimeout(() => {
const sk = generateSecretKey();
setNsec(nip19.nsecEncode(sk));
setStep('secure');
setIsGenerating(false);
}, 750);
};
// Signup: download the nsec to a file and move on to the profile step.
const downloadAndProceed = () => {
try {
downloadNsecFile(nsec);
login.nsec(nsec);
setStep('profile');
} catch {
toast({
title: 'Download failed',
description: 'Could not download the key file. Please copy it manually.',
variant: 'destructive',
});
}
};
// Login: submit the entered nsec.
const handleLogin = () => {
if (!loginNsec.trim()) {
setLoginError('Enter your secret key.');
return;
}
if (!validateNsec(loginNsec)) {
setLoginError('Invalid secret key. Must start with nsec1.');
return;
}
setIsLoggingIn(true);
setLoginError('');
// Timeout gives the UI a chance to repaint before the synchronous login.
setTimeout(() => {
try {
login.nsec(loginNsec);
onClose();
} catch {
setLoginError("Couldn't log in with this key.");
setIsLoggingIn(false);
}
}, 50);
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const content = event.target?.result as string;
if (content && validateNsec(content.trim())) {
setLoginNsec(content.trim());
} else {
setLoginError('File does not contain a valid secret key.');
}
};
reader.onerror = () => setLoginError('Failed to read file.');
reader.readAsText(file);
};
const handleExtensionLogin = async () => {
if (!hasExtension) return;
setIsLoggingIn(true);
try {
await login.extension();
onClose();
} catch (e) {
setLoginError(e instanceof Error ? e.message : 'Extension login failed.');
setIsLoggingIn(false);
}
};
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = '';
if (!file.type.startsWith('image/')) {
toast({ title: 'Please select an image.', variant: 'destructive' });
return;
}
if (file.size > 5 * 1024 * 1024) {
toast({ title: 'Image too large (max 5MB).', variant: 'destructive' });
return;
}
try {
const tags = await uploadFile(file);
const url = tags[0]?.[1];
if (url) setProfileData((prev) => ({ ...prev, picture: url }));
} catch {
toast({ title: 'Upload failed.', variant: 'destructive' });
}
};
const finishSignup = async (skipProfile = false) => {
try {
if (!skipProfile && (profileData.name || profileData.about || profileData.picture)) {
// Defensive guard: only publish kind 0 if the current signer is
// the freshly generated key. If the auto-switch ever fails (e.g.
// a regression in useLoginActions), publishing here would sign
// with the *previous* user's signer and overwrite their kind 0
// metadata — destroying their profile. Refuse rather than risk
// it.
let expectedPubkey: string | null = null;
try {
const decoded = nip19.decode(nsec);
if (decoded.type === 'nsec') {
expectedPubkey = getPublicKey(decoded.data);
}
} catch {
// fall through to the mismatch branch below
}
if (!expectedPubkey || currentUser?.pubkey !== expectedPubkey) {
toast({
title: 'Profile not saved',
description:
'The new account is not active yet, so your profile was not published (this prevents overwriting another account). Try again from your profile settings.',
variant: 'destructive',
});
return;
}
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) });
}
} catch {
toast({
title: 'Profile setup failed',
description: 'Your account was created but the profile could not be saved. You can update it later.',
variant: 'destructive',
});
} finally {
onClose();
}
};
const getTitle = () => {
switch (step) {
case 'welcome':
return 'Welcome';
case 'generate':
return 'Create a Nostr account';
case 'secure':
return 'Save your key';
case 'profile':
return 'Your profile';
case 'login':
return 'Log in';
case 'connect':
return 'Connect signer';
}
};
// Decide whether to render the progress view in place of the QR/button.
// Mobile: flip in as soon as the user taps "Open signer app" (tracked by
// `hasOpenedSigner`) so they see feedback the moment they return from the
// signer. Desktop: keep the QR visible through the `awaiting-connect`
// phase (it's still actionable — they might scan with another device) and
// only swap in once the signer has acknowledged and we're fetching the
// pubkey.
const showProgressView = connectStatus !== null && (
connectStatus === 'getting-public-key' ||
(isMobile && hasOpenedSigner)
);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-sm max-h-[90dvh] p-0 gap-0 overflow-hidden rounded-2xl overflow-y-auto">
<DialogHeader className="px-6 pt-6">
<DialogTitle className="text-lg font-semibold leading-none tracking-tight text-center">
{getTitle()}
</DialogTitle>
</DialogHeader>
<div className="px-6 pb-6 pt-4 space-y-5">
{/* Welcome step — the unified entry point. */}
{step === 'welcome' && (
<div className="space-y-5 text-center">
<div className="flex size-32 text-6xl bg-primary/10 rounded-full items-center justify-center mx-auto">
🔑
</div>
<p className="text-sm text-muted-foreground">
Join with a new Nostr account, or log in with one you already have.
</p>
<div className="space-y-2">
<Button onClick={() => setStep('generate')} className="w-full h-12">
Create a new Nostr account
</Button>
<Button
variant="outline"
onClick={() => setStep('login')}
className="w-full h-12"
>
Log in to an existing account
</Button>
</div>
</div>
)}
{/* Generate step. */}
{step === 'generate' && (
<div className="space-y-5 text-center">
<div className="relative w-20 h-20 mx-auto">
{isGenerating ? (
<div className="absolute inset-0 flex items-center justify-center">
<Loader2 className="w-12 h-12 text-primary animate-spin" />
</div>
) : (
<div className="absolute inset-0 rounded-full bg-primary/10 flex items-center justify-center">
<Key className="w-8 h-8 text-primary" />
</div>
)}
</div>
<div className="space-y-1">
<p className="font-medium">
{isGenerating ? 'Creating your key…' : 'Your key is your identity'}
</p>
<p className="text-sm text-muted-foreground">
{isGenerating
? 'This only takes a moment.'
: "We'll generate a secret key just for you. Keep it safe — it's the only way to log in."}
</p>
</div>
{!isGenerating && (
<Button onClick={generateKey} className="w-full h-12">
Generate key
</Button>
)}
<button
onClick={() => setStep('welcome')}
className="text-sm text-muted-foreground hover:text-foreground"
>
Back
</button>
</div>
)}
{/* Secure step — show + download nsec. */}
{step === 'secure' && (
<div className="space-y-4">
<div className="flex size-14 bg-primary/10 rounded-full items-center justify-center mx-auto">
<Key className="w-7 h-7 text-primary" />
</div>
<p className="text-sm text-muted-foreground text-center">
Store your key somewhere safe. You'll need it to log in again.
</p>
<div className="relative">
<Input
type={showKey ? 'text' : 'password'}
value={nsec}
readOnly
className="pr-10 font-mono"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={() => setShowKey(!showKey)}
>
{showKey ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</div>
<div className="p-3 rounded-lg bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800">
<p className="text-xs text-amber-900 dark:text-amber-300">
This key is your only way to access your account. If you lose it, you lose the account.
</p>
</div>
<Button onClick={downloadAndProceed} className="w-full h-12">
<Download className="w-4 h-4 mr-2" />
Download &amp; continue
</Button>
</div>
)}
{/* Profile step — optional metadata. */}
{step === 'profile' && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground text-center">
Tell others a bit about yourself (optional).
</p>
<div className={`space-y-4 ${isPublishing ? 'opacity-50 pointer-events-none' : ''}`}>
<div className="space-y-1.5">
<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"
/>
</div>
<div className="space-y-1.5">
<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="A little about you…"
className="resize-none"
rows={3}
/>
</div>
<div className="space-y-1.5">
<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://…"
className="flex-1"
/>
<input
type="file"
accept="image/*"
className="hidden"
ref={avatarFileInputRef}
onChange={handleAvatarUpload}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => avatarFileInputRef.current?.click()}
disabled={isUploading}
title="Upload avatar"
>
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Upload className="w-4 h-4" />
)}
</Button>
</div>
</div>
</div>
<div className="space-y-2">
<Button
onClick={() => finishSignup(false)}
disabled={isPublishing}
className="w-full h-12"
>
{isPublishing ? 'Saving…' : 'Finish'}
</Button>
<Button
variant="ghost"
onClick={() => finishSignup(true)}
disabled={isPublishing}
className="w-full"
>
Skip for now
</Button>
</div>
</div>
)}
{/* Login step. */}
{step === 'login' && (
<div className="space-y-4">
{hasExtension ? (
<>
<Button
onClick={handleExtensionLogin}
disabled={isLoggingIn}
className="w-full h-12"
>
{isLoggingIn ? 'Logging in…' : 'Log in with extension'}
</Button>
<Button
variant="outline"
onClick={goToConnect}
className="w-full h-12"
>
Use remote signer
</Button>
<Collapsible open={showMoreOptions} onOpenChange={setShowMoreOptions}>
<CollapsibleTrigger className="w-full flex items-center justify-center gap-1 text-sm text-muted-foreground hover:text-foreground py-2">
<span>Use secret key</span>
<ChevronDown
className={`w-4 h-4 transition-transform ${
showMoreOptions ? 'rotate-180' : ''
}`}
/>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-1">
<NsecLoginForm
loginNsec={loginNsec}
setLoginNsec={setLoginNsec}
loginError={loginError}
setLoginError={setLoginError}
isLoggingIn={isLoggingIn}
onSubmit={handleLogin}
onFileChange={handleFileUpload}
fileInputRef={fileInputRef}
/>
</CollapsibleContent>
</Collapsible>
</>
) : (
<>
<NsecLoginForm
loginNsec={loginNsec}
setLoginNsec={setLoginNsec}
loginError={loginError}
setLoginError={setLoginError}
isLoggingIn={isLoggingIn}
onSubmit={handleLogin}
onFileChange={handleFileUpload}
fileInputRef={fileInputRef}
/>
<Button
variant="outline"
onClick={goToConnect}
className="w-full"
>
Use remote signer
</Button>
</>
)}
<button
onClick={() => setStep('welcome')}
className="w-full text-sm text-muted-foreground hover:text-foreground"
>
Back
</button>
</div>
)}
{/* Connect step — nostrconnect QR + bunker URI fallback. */}
{step === 'connect' && (
<div className="space-y-4">
<div className="flex flex-col items-center space-y-4">
{connectError ? (
<div className="flex flex-col items-center space-y-3 py-4">
<p className="text-sm text-destructive text-center">{connectError}</p>
<Button variant="outline" onClick={handleConnectRetry}>
Try again
</Button>
</div>
) : showProgressView ? (
// Progress view — replaces the QR/button once the handshake
// is under way. Gives the user live feedback through each
// phase so a stuck signer is visibly stuck, not silently
// stuck.
<div className="flex flex-col items-center space-y-4 py-6 w-full">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground text-center min-h-[1.25rem]">
{connectStatusLabel(connectStatus)}
</p>
<button
type="button"
onClick={handleConnectRetry}
className="text-sm text-primary hover:underline underline-offset-4 font-medium"
>
Cancel
</button>
</div>
) : nostrConnectUri ? (
<>
{!isMobile && (
<div className="p-4 bg-white rounded-xl">
<QRCodeCanvas value={nostrConnectUri} size={180} level="M" />
</div>
)}
{isMobile && (
<Button onClick={handleOpenSignerApp} className="w-full h-12">
<ExternalLink className="w-5 h-5 mr-2" />
Open signer app
</Button>
)}
</>
) : (
<div className="flex items-center justify-center h-[100px]">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
)}
</div>
{/* Manual bunker URI fallback. */}
<Collapsible open={showBunkerInput} onOpenChange={setShowBunkerInput}>
<CollapsibleTrigger className="w-full flex items-center justify-center gap-1 text-sm text-muted-foreground hover:text-foreground py-2">
<span>Enter bunker URI manually</span>
{showBunkerInput ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-2">
<Input
value={bunkerUri}
onChange={(e) => setBunkerUri(e.target.value)}
placeholder="bunker://…"
className="text-base md:text-sm"
/>
{bunkerUri && !validateBunkerUri(bunkerUri) && (
<Alert variant="destructive">
<AlertDescription>Invalid bunker URI format.</AlertDescription>
</Alert>
)}
<Button
variant="outline"
onClick={handleBunkerLogin}
disabled={
isLoggingIn || !bunkerUri.trim() || !validateBunkerUri(bunkerUri)
}
className="w-full"
>
{isLoggingIn ? 'Connecting…' : 'Connect'}
</Button>
</CollapsibleContent>
</Collapsible>
<button
onClick={() => setStep('login')}
className="w-full text-sm text-muted-foreground hover:text-foreground"
>
Back
</button>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};
/** Shared nsec input + submit + file-upload block used in the login step. */
interface NsecLoginFormProps {
loginNsec: string;
setLoginNsec: (v: string) => void;
loginError: string;
setLoginError: (v: string) => void;
isLoggingIn: boolean;
onSubmit: () => void;
onFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
fileInputRef: React.RefObject<HTMLInputElement | null>;
}
const NsecLoginForm: React.FC<NsecLoginFormProps> = ({
loginNsec,
setLoginNsec,
loginError,
setLoginError,
isLoggingIn,
onSubmit,
onFileChange,
fileInputRef,
}) => (
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
className="space-y-3"
>
<Input
type="password"
value={loginNsec}
onChange={(e) => {
setLoginNsec(e.target.value);
if (loginError) setLoginError('');
}}
placeholder="nsec1…"
autoComplete="off"
className={loginError ? 'border-destructive focus-visible:ring-destructive' : ''}
/>
{loginError && <p className="text-sm text-destructive">{loginError}</p>}
<div className="flex gap-2">
<Button
type="submit"
disabled={isLoggingIn || !loginNsec.trim()}
className="flex-1"
>
{isLoggingIn ? 'Logging in…' : 'Log in'}
</Button>
<input
type="file"
accept=".txt"
className="hidden"
ref={fileInputRef}
onChange={onFileChange}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="w-4 h-4" />
</Button>
</div>
</form>
);
export default AuthDialog;

View file

@ -1,50 +0,0 @@
import { describe, it, expect } from 'vitest';
import { act, renderHook, waitFor } from '@testing-library/react';
import { generateSecretKey, nip19, getPublicKey } from 'nostr-tools';
import { useNostrLogin } from '@nostrify/react/login';
import { TestApp } from '@/test/TestApp';
import { useLoginActions } from './useLoginActions';
/**
* Regression test: adding a second login while one is already logged in
* must switch the current user to the new login (logins[0]). Without the
* fix, addLogin appends the new login to the end of the array and
* logins[0] remains the previously-logged-in user — leading to the
* destructive bug where the signup profile step signs kind 0 metadata
* with the *original* user's signer, overwriting their profile.
*/
describe('useLoginActions auto-switch', () => {
it('promotes the newly added nsec login to logins[0]', async () => {
const nsec1 = nip19.nsecEncode(generateSecretKey());
const sk2 = generateSecretKey();
const nsec2 = nip19.nsecEncode(sk2);
const pubkey2 = getPublicKey(sk2);
const { result } = renderHook(
() => ({
actions: useLoginActions(),
login: useNostrLogin(),
}),
{ wrapper: TestApp },
);
// NostrLoginProvider renders null while it reads logins from storage,
// so wait for the provider to mount.
await waitFor(() => expect(result.current).not.toBeNull());
act(() => {
result.current.actions.nsec(nsec1);
});
expect(result.current.login.logins).toHaveLength(1);
act(() => {
result.current.actions.nsec(nsec2);
});
expect(result.current.login.logins).toHaveLength(2);
// The newly added login MUST be at index 0 (current user).
expect(result.current.login.logins[0].pubkey).toBe(pubkey2);
});
});

View file

@ -4,95 +4,93 @@
@layer base {
:root {
--radius: 0.75rem;
/* Light theme - Clean with subtle neon accents */
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--foreground: 0 0% 10%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--card: 0 0% 98%;
--card-foreground: 0 0% 10%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--popover-foreground: 0 0% 10%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
/* Darker neon red for light mode */
--primary: 349 85% 45%;
--primary-foreground: 0 0% 100%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--secondary: 0 0% 95%;
--secondary-foreground: 0 0% 10%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--muted: 0 0% 96%;
--muted-foreground: 0 0% 40%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
/* Darker neon lime for light mode */
--accent: 75 80% 40%;
--accent-foreground: 0 0% 100%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--border: 0 0% 90%;
--input: 0 0% 95%;
--ring: 349 85% 45%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 1rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
--sidebar-foreground: 0 0% 10%;
--sidebar-primary: 349 85% 45%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 0 0% 95%;
--sidebar-accent-foreground: 0 0% 10%;
--sidebar-border: 0 0% 90%;
--sidebar-ring: 349 85% 45%;
/* Custom gradient colors */
--gradient-start: 349 85% 45%;
--gradient-end: 75 80% 40%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
/* Pure black THUG2 aesthetic */
--background: 0 0% 0%;
--foreground: 0 0% 100%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--card: 0 0% 5%;
--card-foreground: 0 0% 100%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--popover: 0 0% 5%;
--popover-foreground: 0 0% 100%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
/* Neon red primary */
--primary: 349 100% 50%;
--primary-foreground: 0 0% 100%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--secondary: 0 0% 10%;
--secondary-foreground: 0 0% 100%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--muted: 0 0% 10%;
--muted-foreground: 0 0% 60%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
/* Neon lime accent */
--accent: 75 100% 60%;
--accent-foreground: 0 0% 0%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--border: 349 100% 50% / 0.3;
--input: 0 0% 10%;
--ring: 349 100% 50%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-background: 0 0% 0%;
--sidebar-foreground: 0 0% 100%;
--sidebar-primary: 349 100% 50%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
--sidebar-accent: 0 0% 10%;
--sidebar-accent-foreground: 0 0% 100%;
--sidebar-border: 349 100% 50% / 0.3;
--sidebar-ring: 349 100% 50%;
}
}
@ -105,3 +103,283 @@
@apply bg-background text-foreground;
}
}
/* Leaflet map overrides */
.leaflet-container {
height: 100%;
width: 100%;
border-radius: var(--radius);
z-index: 0;
}
.leaflet-popup-content-wrapper {
border-radius: calc(var(--radius) - 4px);
box-shadow: 0 10px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
}
.leaflet-popup-content {
margin: 0;
}
/* THUG2-inspired neon effects */
@keyframes neon-glow {
0%, 100% { text-shadow: 0 0 10px currentColor, 0 0 20px currentColor, 0 0 30px currentColor, 0 0 40px currentColor; }
50% { text-shadow: 0 0 5px currentColor, 0 0 10px currentColor, 0 0 15px currentColor, 0 0 20px currentColor; }
}
.neon-header {
animation: neon-glow 2s ease-in-out infinite;
font-family: 'Impact', 'Arial Black', sans-serif;
letter-spacing: 0.1em;
}
.neon-red {
color: #ff0040;
text-shadow:
0 0 10px #ff0040,
0 0 20px #ff0040,
0 0 30px #ff0040,
0 0 40px #ff0040,
2px 2px 4px rgba(0,0,0,0.9);
}
.neon-lime {
color: #ccff00;
text-shadow:
0 0 10px #ccff00,
0 0 20px #ccff00,
0 0 30px #ccff00,
0 0 40px #ccff00,
2px 2px 4px rgba(0,0,0,0.9);
}
.neon-orange {
color: #ff6600;
text-shadow:
0 0 10px #ff6600,
0 0 20px #ff6600,
0 0 30px #ff6600;
}
.gradient-text {
background: linear-gradient(135deg, #ff0040 0%, #ff6600 50%, #ccff00 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-slideUp {
animation: slideUp 0.6s ease-out;
}
.animate-pulse-slow {
animation: pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes stagger {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.stagger-children > * {
animation: stagger 0.5s ease-out backwards;
}
.stagger-children > *:nth-child(1) { animation-delay: 0.1s; }
.stagger-children > *:nth-child(2) { animation-delay: 0.2s; }
.stagger-children > *:nth-child(3) { animation-delay: 0.3s; }
.stagger-children > *:nth-child(4) { animation-delay: 0.4s; }
.stagger-children > *:nth-child(5) { animation-delay: 0.5s; }
.stagger-children > *:nth-child(6) { animation-delay: 0.6s; }
.dark .leaflet-tile-pane {
filter: invert(1) hue-rotate(180deg) brightness(0.9) contrast(0.9);
}
.dark .leaflet-control-zoom a {
background-color: hsl(var(--card));
color: hsl(var(--foreground));
border-color: hsl(var(--border));
}
.dark .leaflet-control-zoom a:hover {
background-color: hsl(var(--muted));
}
.dark .leaflet-popup-content-wrapper,
.dark .leaflet-popup-tip {
background-color: hsl(var(--card));
color: hsl(var(--foreground));
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: hsl(var(--muted));
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 0 0 hsl(var(--primary) / 0.4); }
50% { box-shadow: 0 0 0 8px hsl(var(--primary) / 0); }
}
.animate-fadeIn {
animation: fadeIn 0.4s ease-out forwards;
}
.animate-slideUp {
animation: slideUp 0.5s ease-out forwards;
}
.animate-pulse-glow {
animation: pulse-glow 2s infinite;
}
/* Stagger animation for lists */
.stagger-children > * {
opacity: 0;
animation: fadeIn 0.4s ease-out forwards;
}
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
.stagger-children > *:nth-child(2) { animation-delay: 50ms; }
.stagger-children > *:nth-child(3) { animation-delay: 100ms; }
.stagger-children > *:nth-child(4) { animation-delay: 150ms; }
.stagger-children > *:nth-child(5) { animation-delay: 200ms; }
.stagger-children > *:nth-child(6) { animation-delay: 250ms; }
.stagger-children > *:nth-child(7) { animation-delay: 300ms; }
.stagger-children > *:nth-child(8) { animation-delay: 350ms; }
/* Gradient text */
.gradient-text {
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--accent)) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Glass effect */
.glass {
background: hsl(var(--card) / 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
/* Safe area insets for iOS notched devices */
.h-safe-area-bottom {
height: env(safe-area-inset-bottom, 0px);
}
.pb-safe {
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.pt-safe {
padding-top: env(safe-area-inset-top, 0px);
}
.safe-area-top {
padding-top: env(safe-area-inset-top, 0px);
}
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.safe-area-left {
padding-left: env(safe-area-inset-left, 0px);
}
.safe-area-right {
padding-right: env(safe-area-inset-right, 0px);
}
/* Mobile bottom nav spacing - add padding to content to avoid overlap */
@media (max-width: 768px) {
.mobile-nav-padding {
padding-bottom: calc(72px + env(safe-area-inset-bottom, 0px));
}
}
/* Prevent horizontal overflow on mobile */
html, body {
overflow-x: hidden;
max-width: 100vw;
}
/* Improve touch targets for mobile */
@media (max-width: 768px) {
button, a, input, select, textarea {
min-height: 44px;
}
/* Better tap highlight on mobile */
a, button {
-webkit-tap-highlight-color: rgba(255, 0, 64, 0.1);
}
}
/* Fix input zoom on iOS */
@media screen and (max-width: 768px) {
input, select, textarea {
font-size: 16px !important;
}
}
/* Hide scrollbar utility - useful for horizontal scrolling filters */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
/* Extra small breakpoint for very small phones */
@media (min-width: 375px) {
.xs\:inline {
display: inline;
}
}

View file

@ -1,78 +0,0 @@
import type { NostrEvent } from "@nostrify/nostrify";
import type { BlossomServerMetadata } from "@/contexts/AppContext";
/** Normalize a Blossom server URL for deduplication (lowercase, strip trailing slashes). */
function normalizeUrl(url: string): string {
return url.toLowerCase().replace(/\/+$/, "");
}
/** Parse a kind 10063 Blossom server list event into validated server URLs. */
export function parseBlossomServerList(event: NostrEvent): string[] {
return event.tags
.filter(([name]) => name === "server")
.map(([, url]) => url)
.filter((url) => {
try {
new URL(url);
return true;
} catch {
return false;
}
});
}
/**
* App default Blossom servers used as a fallback when the user has no kind 10063
* server list, and optionally merged with user servers via `useAppBlossomServers`.
*/
export const APP_BLOSSOM_SERVERS: BlossomServerMetadata = {
servers: [
"https://blossom.ditto.pub/",
"https://blossom.dreamith.to/",
"https://blossom.primal.net/",
],
updatedAt: 0,
};
/**
* Get the effective Blossom server list based on user settings.
*
* - If `useAppBlossomServers` is true, merges app servers with user servers (deduped,
* app servers first per BUD-03's "most trusted first" ordering).
* - If `useAppBlossomServers` is false, returns only the user's servers (deduped).
*/
export function getEffectiveBlossomServers(
userMeta: BlossomServerMetadata,
useAppBlossomServers: boolean,
): string[] {
if (!useAppBlossomServers) {
return deduplicateServers(userMeta.servers);
}
const seen = new Set<string>();
const merged: string[] = [];
for (const url of [...APP_BLOSSOM_SERVERS.servers, ...userMeta.servers]) {
const normalized = normalizeUrl(url);
if (!seen.has(normalized)) {
seen.add(normalized);
merged.push(url);
}
}
return merged;
}
/** Deduplicate servers by normalized URL, preserving order. */
function deduplicateServers(servers: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const url of servers) {
const normalized = normalizeUrl(url);
if (!seen.has(normalized)) {
seen.add(normalized);
result.push(url);
}
}
return result;
}

View file

@ -1,15 +0,0 @@
import type { RelayMetadata } from '@/contexts/AppContext';
/**
* App default relays. Used as the initial `relayMetadata` for new users and as
* a fallback when the user has no NIP-65 relay list configured (e.g. during
* nostrconnect handshakes before any user relays have been loaded).
*/
export const APP_RELAYS: RelayMetadata = {
relays: [
{ url: 'wss://relay.ditto.pub/', read: true, write: true },
{ url: 'wss://relay.dreamith.to/', read: true, write: true },
{ url: 'wss://relay.primal.net/', read: true, write: true },
],
updatedAt: 0,
};