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:
parent
540eeead2f
commit
122bdfb102
5 changed files with 341 additions and 1124 deletions
|
|
@ -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 & 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;
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
404
src/index.css
404
src/index.css
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
Loading…
Reference in a new issue