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 {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.75rem;
|
/* Light theme - Clean with subtle neon accents */
|
||||||
|
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
--foreground: 222.2 84% 4.9%;
|
--foreground: 0 0% 10%;
|
||||||
|
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 98%;
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
--card-foreground: 0 0% 10%;
|
||||||
|
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
--popover-foreground: 0 0% 10%;
|
||||||
|
|
||||||
--primary: 222.2 47.4% 11.2%;
|
/* Darker neon red for light mode */
|
||||||
--primary-foreground: 210 40% 98%;
|
--primary: 349 85% 45%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--secondary: 210 40% 96.1%;
|
--secondary: 0 0% 95%;
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
--secondary-foreground: 0 0% 10%;
|
||||||
|
|
||||||
--muted: 210 40% 96.1%;
|
--muted: 0 0% 96%;
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
--muted-foreground: 0 0% 40%;
|
||||||
|
|
||||||
--accent: 210 40% 96.1%;
|
/* Darker neon lime for light mode */
|
||||||
--accent-foreground: 222.2 47.4% 11.2%;
|
--accent: 75 80% 40%;
|
||||||
|
--accent-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84% 60%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--border: 214.3 31.8% 91.4%;
|
--border: 0 0% 90%;
|
||||||
--input: 214.3 31.8% 91.4%;
|
--input: 0 0% 95%;
|
||||||
--ring: 222.2 84% 4.9%;
|
--ring: 349 85% 45%;
|
||||||
|
|
||||||
--chart-1: 12 76% 61%;
|
--radius: 1rem;
|
||||||
--chart-2: 173 58% 39%;
|
|
||||||
--chart-3: 197 37% 24%;
|
|
||||||
--chart-4: 43 74% 66%;
|
|
||||||
--chart-5: 27 87% 67%;
|
|
||||||
|
|
||||||
--sidebar-background: 0 0% 98%;
|
--sidebar-background: 0 0% 98%;
|
||||||
--sidebar-foreground: 240 5.3% 26.1%;
|
--sidebar-foreground: 0 0% 10%;
|
||||||
--sidebar-primary: 240 5.9% 10%;
|
--sidebar-primary: 349 85% 45%;
|
||||||
--sidebar-primary-foreground: 0 0% 98%;
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
--sidebar-accent: 240 4.8% 95.9%;
|
--sidebar-accent: 0 0% 95%;
|
||||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
--sidebar-accent-foreground: 0 0% 10%;
|
||||||
--sidebar-border: 220 13% 91%;
|
--sidebar-border: 0 0% 90%;
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
--sidebar-ring: 349 85% 45%;
|
||||||
|
|
||||||
|
/* Custom gradient colors */
|
||||||
|
--gradient-start: 349 85% 45%;
|
||||||
|
--gradient-end: 75 80% 40%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 222.2 84% 4.9%;
|
/* Pure black THUG2 aesthetic */
|
||||||
--foreground: 210 40% 98%;
|
--background: 0 0% 0%;
|
||||||
|
--foreground: 0 0% 100%;
|
||||||
|
|
||||||
--card: 222.2 84% 4.9%;
|
--card: 0 0% 5%;
|
||||||
--card-foreground: 210 40% 98%;
|
--card-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--popover: 222.2 84% 4.9%;
|
--popover: 0 0% 5%;
|
||||||
--popover-foreground: 210 40% 98%;
|
--popover-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--primary: 210 40% 98%;
|
/* Neon red primary */
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
--primary: 349 100% 50%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
--secondary: 0 0% 10%;
|
||||||
--secondary-foreground: 210 40% 98%;
|
--secondary-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--muted: 217.2 32.6% 17.5%;
|
--muted: 0 0% 10%;
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
--muted-foreground: 0 0% 60%;
|
||||||
|
|
||||||
--accent: 217.2 32.6% 17.5%;
|
/* Neon lime accent */
|
||||||
--accent-foreground: 210 40% 98%;
|
--accent: 75 100% 60%;
|
||||||
|
--accent-foreground: 0 0% 0%;
|
||||||
|
|
||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 84% 60%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
|
||||||
--border: 217.2 32.6% 17.5%;
|
--border: 349 100% 50% / 0.3;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--input: 0 0% 10%;
|
||||||
--ring: 212.7 26.8% 83.9%;
|
--ring: 349 100% 50%;
|
||||||
|
|
||||||
--chart-1: 220 70% 50%;
|
--sidebar-background: 0 0% 0%;
|
||||||
--chart-2: 160 60% 45%;
|
--sidebar-foreground: 0 0% 100%;
|
||||||
--chart-3: 30 80% 55%;
|
--sidebar-primary: 349 100% 50%;
|
||||||
--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-primary-foreground: 0 0% 100%;
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
--sidebar-accent: 240 3.7% 15.9%;
|
--sidebar-accent: 0 0% 10%;
|
||||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
--sidebar-accent-foreground: 0 0% 100%;
|
||||||
--sidebar-border: 240 3.7% 15.9%;
|
--sidebar-border: 349 100% 50% / 0.3;
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
--sidebar-ring: 349 100% 50%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,3 +103,283 @@
|
||||||
@apply bg-background text-foreground;
|
@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