Option B: a lightweight reorder / show-hide list for the desktop header. - useDesktopNavPreferences: separate localStorage-backed preference for which catalog items appear as primary top-nav tabs and their order (Map fixed first, no hard slot cap). Reuses the shared NAV_CATALOG. - DesktopNavManager: settings UI to reorder primary tabs (up/down) and toggle items between the top bar and the "More" menu; Map locked; reset to default. - Layout desktop header now renders fixed Map + user's primary tabs, and the "More" dropdown dynamically lists the hidden catalog items (auth-gated), so each item appears in exactly one place. - Manager shown only on md+ screens (mobile uses the bottom-bar customizer). Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
946 lines
40 KiB
TypeScript
946 lines
40 KiB
TypeScript
import { useSeoMeta } from '@unhead/react';
|
|
import { Link, useSearchParams } from 'react-router-dom';
|
|
import { Layout } from '@/components/Layout';
|
|
import { EditProfileForm } from '@/components/EditProfileForm';
|
|
import { RelayListManager } from '@/components/RelayListManager';
|
|
import { FollowListManager } from '@/components/FollowListManager';
|
|
import { WalletModal } from '@/components/WalletModal';
|
|
import { SecuritySettings } from '@/components/SecuritySettings';
|
|
import { RelatrSettings } from '@/components/RelatrSettings';
|
|
import { LanguageSelector } from '@/components/LanguageToggle';
|
|
import { DesktopNavManager } from '@/components/DesktopNavManager';
|
|
import { MyDataTab } from '@/components/MyDataTab';
|
|
import { useTranslation } from '@/contexts/LanguageContext';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import { useNostr } from '@nostrify/react';
|
|
import { NSchema as n } from '@nostrify/nostrify';
|
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { LoginArea } from '@/components/auth/LoginArea';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useAppContext } from '@/hooks/useAppContext';
|
|
import { toast } from '@/hooks/useToast';
|
|
import { cn } from '@/lib/utils';
|
|
import QRCode from 'qrcode';
|
|
import {
|
|
User,
|
|
Radio,
|
|
Wallet,
|
|
Wrench,
|
|
RotateCcw,
|
|
Shield,
|
|
ExternalLink,
|
|
Languages,
|
|
RefreshCw,
|
|
AlertCircle,
|
|
Database,
|
|
Package,
|
|
ChevronRight,
|
|
ChevronLeft,
|
|
Network,
|
|
Copy,
|
|
Check,
|
|
Smartphone,
|
|
Download,
|
|
Globe,
|
|
} from 'lucide-react';
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
type Section =
|
|
| null
|
|
| 'profile'
|
|
| 'follows'
|
|
| 'nip05'
|
|
| 'security'
|
|
| 'mydata'
|
|
| 'language'
|
|
| 'appearance'
|
|
| 'relays'
|
|
| 'crewnetwork'
|
|
| 'wallet'
|
|
| 'stash'
|
|
| 'advanced';
|
|
|
|
// ─── Back header ──────────────────────────────────────────────────────────────
|
|
|
|
function BackHeader({ title, onBack }: { title: string; onBack: () => void }) {
|
|
return (
|
|
<div className="flex items-center gap-3 mb-5">
|
|
<button
|
|
onClick={onBack}
|
|
className="flex items-center justify-center w-8 h-8 rounded-xl bg-white/5 hover:bg-white/10 active:scale-95 transition-all"
|
|
>
|
|
<ChevronLeft className="h-4 w-4 text-white" />
|
|
</button>
|
|
<h2 className="text-lg font-black uppercase tracking-wide text-white">{title}</h2>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Menu row ────────────────────────────────────────────────────────────────
|
|
|
|
function MenuRow({
|
|
icon: Icon,
|
|
label,
|
|
sublabel,
|
|
iconColor,
|
|
iconBg,
|
|
onClick,
|
|
href,
|
|
badge,
|
|
}: {
|
|
icon: React.ComponentType<{ className?: string }>;
|
|
label: string;
|
|
sublabel?: string;
|
|
iconColor: string;
|
|
iconBg: string;
|
|
onClick?: () => void;
|
|
href?: string;
|
|
badge?: string;
|
|
}) {
|
|
const inner = (
|
|
<div className="flex items-center gap-3 w-full py-3 px-4 hover:bg-white/5 active:scale-[0.99] transition-all">
|
|
<div className={cn('w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0', iconBg)}>
|
|
<Icon className={cn('h-4 w-4', iconColor)} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-bold text-white leading-tight">{label}</p>
|
|
{sublabel && <p className="text-xs text-muted-foreground leading-tight mt-0.5">{sublabel}</p>}
|
|
</div>
|
|
{badge && (
|
|
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-[#ccff00]/20 text-[#ccff00] border border-[#ccff00]/30 flex-shrink-0">
|
|
{badge}
|
|
</span>
|
|
)}
|
|
<ChevronRight className="h-4 w-4 text-muted-foreground/50 flex-shrink-0" />
|
|
</div>
|
|
);
|
|
|
|
if (href) {
|
|
return <Link to={href} className="block">{inner}</Link>;
|
|
}
|
|
|
|
return <button onClick={onClick} className="w-full text-left">{inner}</button>;
|
|
}
|
|
|
|
// ─── Menu group ───────────────────────────────────────────────────────────────
|
|
|
|
function MenuGroup({ title, children }: { title: string; children: React.ReactNode }) {
|
|
return (
|
|
<div>
|
|
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 px-4 mb-1">{title}</p>
|
|
<div className="rounded-2xl border border-white/8 overflow-hidden divide-y divide-white/5 bg-white/2">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Profile sub-screen ───────────────────────────────────────────────────────
|
|
|
|
const PROFILE_FALLBACK_RELAYS = ['wss://purplepag.es', 'wss://relay.damus.io', 'wss://nos.lol'];
|
|
|
|
function ProfileScreen({ onBack }: { onBack: () => void }) {
|
|
const { user, metadata } = useCurrentUser();
|
|
const { nostr } = useNostr();
|
|
const queryClient = useQueryClient();
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
|
|
const handleRefresh = async () => {
|
|
if (!user) return;
|
|
setIsRefreshing(true);
|
|
try {
|
|
queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] });
|
|
let events = await nostr.query([{ kinds: [0], authors: [user.pubkey], limit: 1 }], { signal: AbortSignal.timeout(8000) });
|
|
if (events.length === 0) {
|
|
const fallback = nostr.group(PROFILE_FALLBACK_RELAYS);
|
|
events = await fallback.query([{ kinds: [0], authors: [user.pubkey], limit: 1 }], { signal: AbortSignal.timeout(8000) });
|
|
}
|
|
if (events.length > 0) {
|
|
const profileMetadata = n.json().pipe(n.metadata()).parse(events[0].content);
|
|
queryClient.setQueryData(['author', user.pubkey], { metadata: profileMetadata, event: events[0] });
|
|
toast({ title: 'Profile refreshed!', description: `Found: ${profileMetadata.name || 'unnamed'}` });
|
|
} else {
|
|
toast({ title: 'Profile not found', description: 'Not found on any relay.', variant: 'destructive' });
|
|
}
|
|
} catch {
|
|
toast({ title: 'Refresh failed', description: 'Could not connect to relays.', variant: 'destructive' });
|
|
} finally {
|
|
setIsRefreshing(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<BackHeader title="Edit Profile" onBack={onBack} />
|
|
{!user ? (
|
|
<div className="text-center py-16">
|
|
<User className="h-10 w-10 mx-auto mb-3 text-muted-foreground/40" />
|
|
<p className="text-muted-foreground text-sm mb-5">Log in to edit your profile</p>
|
|
<LoginArea className="justify-center" />
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{!metadata?.name && (
|
|
<div className="flex items-center gap-3 p-3 rounded-2xl bg-yellow-950/30 border border-yellow-500/40">
|
|
<AlertCircle className="h-4 w-4 text-yellow-500 flex-shrink-0" />
|
|
<p className="text-xs text-yellow-200 flex-1">Profile not loaded from relays.</p>
|
|
<Button onClick={handleRefresh} disabled={isRefreshing} size="sm" variant="outline" className="border-yellow-500/50 text-yellow-400 hover:bg-yellow-500/10 flex-shrink-0 h-8">
|
|
<RefreshCw className={cn('h-3.5 w-3.5', isRefreshing && 'animate-spin')} />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
<EditProfileForm />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── NIP-05 sub-screen ────────────────────────────────────────────────────────
|
|
|
|
function Nip05Screen({ onBack }: { onBack: () => void }) {
|
|
const providers = [
|
|
{ name: 'nostr.directory', desc: 'Free NIP-05 addresses', url: 'https://nostr.directory', free: true },
|
|
{ name: 'nip05.social', desc: 'Free verification service', url: 'https://nip05.social', free: true },
|
|
{ name: 'nostrplebs.com', desc: '~$3/month · Custom domains', url: 'https://nostrplebs.com', free: false },
|
|
{ name: 'nostr.band', desc: 'One-time fee · Analytics included', url: 'https://nostr.band', free: false },
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<BackHeader title="NIP-05 Verification" onBack={onBack} />
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
|
Get a verified Nostr address (like name@domain.com) to build trust. Required for creating events, coaching profiles, and shops.
|
|
</p>
|
|
<div className="rounded-2xl border border-white/8 overflow-hidden divide-y divide-white/5">
|
|
{providers.map((p) => (
|
|
<a
|
|
key={p.name}
|
|
href={p.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center justify-between px-4 py-3.5 hover:bg-white/5 active:scale-[0.99] transition-all"
|
|
>
|
|
<div>
|
|
<p className="text-sm font-bold text-white">{p.name}</p>
|
|
<p className="text-xs text-muted-foreground">{p.desc}</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
<span className={cn('text-[10px] font-bold px-2 py-0.5 rounded-full border', p.free ? 'bg-green-500/15 text-green-400 border-green-500/30' : 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30')}>
|
|
{p.free ? 'Free' : 'Paid'}
|
|
</span>
|
|
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground" />
|
|
</div>
|
|
</a>
|
|
))}
|
|
</div>
|
|
<div className="p-3 rounded-2xl bg-[#ccff00]/5 border border-[#ccff00]/20 text-xs text-muted-foreground">
|
|
After getting your NIP-05, add it in the Profile field and save.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Relays sub-screen ────────────────────────────────────────────────────────
|
|
|
|
function RelaysScreen({ onBack }: { onBack: () => void }) {
|
|
const { user } = useCurrentUser();
|
|
const { config, updateConfig } = useAppContext();
|
|
const useSkateRelay = config.useSkateRelay ?? true;
|
|
|
|
const handleToggle = () => {
|
|
updateConfig((current) => ({ ...current, useSkateRelay: !useSkateRelay }));
|
|
toast({
|
|
title: useSkateRelay ? 'Community relay disabled' : 'Community relay enabled',
|
|
description: useSkateRelay ? 'Using only your configured relays.' : 'SkateSpots relay included.',
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<BackHeader title="Relays" onBack={onBack} />
|
|
<div className="space-y-4">
|
|
{user && (
|
|
<div className="flex items-center justify-between p-4 rounded-2xl bg-white/3 border border-white/8">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-bold text-white">Community Relay</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
{useSkateRelay ? 'Syncing with global skate community' : 'Using only personal relays'}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={handleToggle}
|
|
className={cn(
|
|
'relative w-11 h-6 rounded-full transition-all flex-shrink-0',
|
|
useSkateRelay ? 'bg-[#ccff00]' : 'bg-white/10'
|
|
)}
|
|
>
|
|
<span className={cn(
|
|
'absolute top-0.5 w-5 h-5 rounded-full bg-black transition-all',
|
|
useSkateRelay ? 'left-[calc(100%-22px)]' : 'left-0.5'
|
|
)} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
<RelayListManager />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Appearance sub-screen ────────────────────────────────────────────────────
|
|
|
|
function AppearanceScreen({ onBack }: { onBack: () => void }) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div>
|
|
<BackHeader title="Appearance" onBack={onBack} />
|
|
<div className="space-y-4">
|
|
<div>
|
|
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 mb-2">Language</p>
|
|
<div className="rounded-2xl border border-white/8 p-4 bg-white/2">
|
|
<LanguageSelector />
|
|
</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-white/8 p-5 bg-white/2 text-center">
|
|
<div className="text-3xl mb-3">🌙</div>
|
|
<p className="font-black text-white uppercase tracking-wide text-sm mb-2">Dark Mode Only</p>
|
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
SkateSpots runs a dark neon aesthetic inspired by skate culture. Black background, neon pink, lime, and orange.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Desktop top-nav customization (Option B). Only relevant on wider
|
|
screens, so hide the control on mobile where the bottom-bar
|
|
customizer is used instead. */}
|
|
<div className="hidden md:block">
|
|
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 mb-2">Navigation</p>
|
|
<div className="rounded-2xl border border-white/8 p-4 bg-white/2">
|
|
<DesktopNavManager />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Advanced sub-screen ──────────────────────────────────────────────────────
|
|
|
|
function AdvancedScreen({ onBack }: { onBack: () => void }) {
|
|
const handleResetSasquatch = () => {
|
|
localStorage.removeItem('sasquatch-minimized');
|
|
alert('The Sasquatch has been expanded! 🦧 Refresh the page to see him.');
|
|
window.location.reload();
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<BackHeader title="Advanced" onBack={onBack} />
|
|
<div className="space-y-4">
|
|
<RelatrSettings />
|
|
<div className="rounded-2xl border border-amber-500/20 overflow-hidden bg-white/2">
|
|
<div className="px-4 py-3.5 border-b border-white/5">
|
|
<p className="text-sm font-bold text-amber-400">🦧 Sasquatch Guide</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">Reset the onboarding guide to expanded view</p>
|
|
</div>
|
|
<div className="px-4 py-3.5">
|
|
<button
|
|
onClick={handleResetSasquatch}
|
|
className="flex items-center gap-2 text-sm font-bold text-amber-400 hover:text-amber-300 transition-colors"
|
|
>
|
|
<RotateCcw className="h-4 w-4" />
|
|
Expand The Sasquatch
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Wallet sub-screen ────────────────────────────────────────────────────────
|
|
|
|
function WalletScreen({ onBack }: { onBack: () => void }) {
|
|
const { user } = useCurrentUser();
|
|
return (
|
|
<div>
|
|
<BackHeader title="Wallet" onBack={onBack} />
|
|
{!user ? (
|
|
<div className="text-center py-16">
|
|
<Wallet className="h-10 w-10 mx-auto mb-3 text-muted-foreground/40" />
|
|
<p className="text-muted-foreground text-sm mb-5">Log in to manage your wallet</p>
|
|
<LoginArea className="justify-center" />
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<Link to="/wallet" className="flex items-center justify-between p-4 rounded-2xl bg-orange-500/10 border border-orange-500/20 hover:bg-orange-500/15 active:scale-[0.99] transition-all">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-9 h-9 rounded-xl bg-orange-500/20 flex items-center justify-center">
|
|
<Wallet className="h-4 w-4 text-orange-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-bold text-white">Open Wallet</p>
|
|
<p className="text-xs text-muted-foreground">Balance, send, receive</p>
|
|
</div>
|
|
</div>
|
|
<ChevronRight className="h-4 w-4 text-muted-foreground/50" />
|
|
</Link>
|
|
|
|
<WalletModal>
|
|
<button className="w-full flex items-center justify-between p-4 rounded-2xl bg-purple-500/10 border border-purple-500/20 hover:bg-purple-500/15 active:scale-[0.99] transition-all">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-9 h-9 rounded-xl bg-purple-500/20 flex items-center justify-center">
|
|
<ExternalLink className="h-4 w-4 text-purple-400" />
|
|
</div>
|
|
<div className="text-left">
|
|
<p className="text-sm font-bold text-white">Connect NWC Wallet</p>
|
|
<p className="text-xs text-muted-foreground">Alby, Minibits, Zeus, Coinos</p>
|
|
</div>
|
|
</div>
|
|
<ChevronRight className="h-4 w-4 text-muted-foreground/50" />
|
|
</button>
|
|
</WalletModal>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Crew Network sub-screen ──────────────────────────────────────────────────
|
|
|
|
const NOSTR_VPN_APP_STORE = 'https://apps.apple.com/app/nostr-vpn/id6785410348';
|
|
const NOSTR_VPN_GITHUB = 'https://github.com/mmalmi/nostr-vpn/releases/latest';
|
|
const NOSTR_VPN_TESTFLIGHT = 'https://testflight.apple.com/join/58sg4agv';
|
|
const NOSTR_VPN_ANDROID = 'https://github.com/mmalmi/nostr-vpn/releases/latest';
|
|
|
|
/** Detect the user's OS/platform to surface the most relevant download first. */
|
|
function detectPlatform(): 'ios' | 'android' | 'desktop' {
|
|
const ua = navigator.userAgent;
|
|
if (/android/i.test(ua)) return 'android';
|
|
if (/iphone|ipad|ipod/i.test(ua)) return 'ios';
|
|
return 'desktop';
|
|
}
|
|
|
|
const ALL_PLATFORMS = [
|
|
{ id: 'ios', label: 'iOS App Store', sublabel: 'iPhone & iPad', icon: Smartphone, url: NOSTR_VPN_APP_STORE, color: 'text-blue-400', bg: 'bg-blue-500/15', badge: 'Free' },
|
|
{ id: 'testflight',label: 'iOS TestFlight', sublabel: 'Beta builds', icon: Smartphone, url: NOSTR_VPN_TESTFLIGHT, color: 'text-indigo-400', bg: 'bg-indigo-500/15', badge: 'Beta' },
|
|
{ id: 'android', label: 'Android APK', sublabel: 'arm64 devices', icon: Smartphone, url: NOSTR_VPN_ANDROID, color: 'text-green-400', bg: 'bg-green-500/15', badge: 'APK' },
|
|
{ id: 'desktop', label: 'macOS / Linux / Windows',sublabel: 'Native desktop apps', icon: Download, url: NOSTR_VPN_GITHUB, color: 'text-amber-400', bg: 'bg-amber-500/15', badge: 'Open source' },
|
|
];
|
|
|
|
function CrewNetworkScreen({ onBack }: { onBack: () => void }) {
|
|
const { user, metadata } = useCurrentUser();
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const [copied, setCopied] = useState(false);
|
|
const [qrReady, setQrReady] = useState(false);
|
|
const [qrError, setQrError] = useState(false);
|
|
const [openingApp, setOpeningApp] = useState(false);
|
|
|
|
// Detect platform once — sort most-relevant download to the top.
|
|
const platform = detectPlatform();
|
|
const platforms = [...ALL_PLATFORMS].sort((a) => {
|
|
if (platform === 'ios' && (a.id === 'ios' || a.id === 'testflight')) return -1;
|
|
if (platform === 'android' && a.id === 'android') return -1;
|
|
if (platform === 'desktop' && a.id === 'desktop') return -1;
|
|
return 0;
|
|
});
|
|
|
|
// Build the join-request deep link using the user's npub as device identity.
|
|
// Format: nvpn://join-request?pubkey=<hex>&name=<displayName>&relay=<relay>
|
|
const joinUri = user
|
|
? `nvpn://join-request?pubkey=${user.pubkey}&name=${encodeURIComponent(metadata?.name ?? 'Skater')}&relay=${encodeURIComponent('wss://goskatespots.nostr1.com')}`
|
|
: '';
|
|
|
|
const renderQR = useCallback(async () => {
|
|
if (!canvasRef.current || !joinUri) return;
|
|
try {
|
|
setQrError(false);
|
|
await QRCode.toCanvas(canvasRef.current, joinUri, {
|
|
width: 220,
|
|
margin: 2,
|
|
color: { dark: '#000000', light: '#ffffff' },
|
|
errorCorrectionLevel: 'M',
|
|
});
|
|
setQrReady(true);
|
|
} catch {
|
|
setQrError(true);
|
|
}
|
|
}, [joinUri]);
|
|
|
|
useEffect(() => {
|
|
renderQR();
|
|
}, [renderQR]);
|
|
|
|
const handleCopy = async () => {
|
|
if (!joinUri) return;
|
|
try {
|
|
await navigator.clipboard.writeText(joinUri);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2500);
|
|
toast({ title: 'Copied!', description: 'Join request link copied to clipboard.' });
|
|
} catch {
|
|
toast({ title: 'Copy failed', description: 'Could not access clipboard.', variant: 'destructive' });
|
|
}
|
|
};
|
|
|
|
// Save the QR canvas as a PNG the user can share.
|
|
const handleDownloadQR = () => {
|
|
if (!canvasRef.current || !qrReady) return;
|
|
const link = document.createElement('a');
|
|
link.download = 'nostr-vpn-join-request.png';
|
|
link.href = canvasRef.current.toDataURL('image/png');
|
|
link.click();
|
|
};
|
|
|
|
// Open the deep link on the device. On iOS Safari, window.location.href inside
|
|
// a user-gesture handler is the only reliable way to fire custom schemes.
|
|
// After 2 s with no visibility change, assume the app isn't installed and
|
|
// redirect to the correct store page.
|
|
const handleOpenApp = () => {
|
|
if (!joinUri) return;
|
|
setOpeningApp(true);
|
|
|
|
const fallbackUrl = platform === 'android' ? NOSTR_VPN_ANDROID : NOSTR_VPN_APP_STORE;
|
|
|
|
const timeout = setTimeout(() => {
|
|
// Still here → app not installed → go to store/release page
|
|
window.location.href = fallbackUrl;
|
|
}, 2000);
|
|
|
|
// Cancel the fallback if the page loses focus (app opened successfully).
|
|
const cancel = () => {
|
|
clearTimeout(timeout);
|
|
setOpeningApp(false);
|
|
};
|
|
window.addEventListener('blur', cancel, { once: true });
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.hidden) cancel();
|
|
}, { once: true });
|
|
|
|
window.location.href = joinUri;
|
|
};
|
|
|
|
// Steps adapt: if the user is logged out, step 2 says "log in" instead of "show QR".
|
|
const steps = [
|
|
{ n: '1', text: 'Install nostr-vpn on your device (free, open source)' },
|
|
{ n: '2', text: user ? 'Show your QR code above to a crew admin' : 'Log in to SkateSpots to generate your personal QR' },
|
|
{ n: '3', text: 'Admin approves — you receive a cryptographically signed roster' },
|
|
{ n: '4', text: 'WireGuard-grade encrypted mesh tunnel connects you to your crew' },
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<BackHeader title="Crew Network" onBack={onBack} />
|
|
<div className="space-y-5">
|
|
|
|
{/* Hero explainer */}
|
|
<div className="relative rounded-2xl overflow-hidden border border-[#ccff00]/20 bg-gradient-to-br from-[#ccff00]/5 to-transparent p-5">
|
|
<div className="flex items-start gap-3">
|
|
<div className="w-10 h-10 rounded-2xl bg-[#ccff00]/15 flex items-center justify-center flex-shrink-0 mt-0.5">
|
|
<Shield className="h-5 w-5 text-[#ccff00]" aria-hidden="true" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-black text-white uppercase tracking-wide leading-tight">Private Encrypted Mesh</p>
|
|
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
|
<strong className="text-white">nostr-vpn</strong> turns your Nostr keys into a private WireGuard mesh for your crew.
|
|
No accounts, no central server — your key is your identity. Your crew, your network.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* QR + join request */}
|
|
{user ? (
|
|
<div className="space-y-3">
|
|
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 px-1">
|
|
Your Join Request
|
|
</p>
|
|
<div className="rounded-2xl border border-white/8 bg-white/2 p-5 flex flex-col items-center gap-4">
|
|
|
|
{/* QR canvas — fixed container prevents layout shift */}
|
|
<div className="relative w-[236px] h-[236px] flex-shrink-0">
|
|
<div className="absolute inset-0 rounded-xl overflow-hidden bg-white shadow-lg shadow-black/40 flex items-center justify-center">
|
|
{/* Loading spinner — shown until QR renders */}
|
|
{!qrReady && !qrError && (
|
|
<div
|
|
role="status"
|
|
aria-label="Generating QR code"
|
|
className="flex flex-col items-center gap-2"
|
|
>
|
|
<div className="w-8 h-8 rounded-full border-2 border-black/15 border-t-black animate-spin" />
|
|
<p className="text-[10px] text-black/40 font-medium">Generating…</p>
|
|
</div>
|
|
)}
|
|
{/* Error state */}
|
|
{qrError && (
|
|
<div className="flex flex-col items-center gap-2 px-4 text-center">
|
|
<AlertCircle className="h-6 w-6 text-red-400" />
|
|
<p className="text-[11px] text-black/60">QR failed to render</p>
|
|
<button
|
|
onClick={renderQR}
|
|
className="text-[11px] font-bold text-blue-600 underline"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
)}
|
|
<canvas
|
|
ref={canvasRef}
|
|
role="img"
|
|
aria-label="nostr-vpn join request QR code — scan with the nostr-vpn app to join your crew network"
|
|
className={cn(
|
|
'block p-2 transition-opacity duration-300',
|
|
qrReady ? 'opacity-100' : 'opacity-0 absolute'
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="w-full space-y-2.5 text-center">
|
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
Show this QR to an admin in the nostr-vpn app.
|
|
They scan → approve → you're on the encrypted mesh.
|
|
</p>
|
|
|
|
{/* URI display — improved contrast, selectable */}
|
|
<div className="rounded-xl bg-black/40 border border-white/10 px-3 py-2.5 text-left">
|
|
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 mb-1">Join URI</p>
|
|
<p
|
|
className="text-[10px] font-mono text-white/70 break-all leading-relaxed select-all cursor-text"
|
|
title="Tap to select all, then copy"
|
|
>
|
|
{joinUri.length > 130 ? joinUri.slice(0, 130) + '…' : joinUri}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Actions row */}
|
|
<div className="flex gap-2 pt-0.5">
|
|
<button
|
|
onClick={handleCopy}
|
|
aria-label={copied ? 'Link copied to clipboard' : 'Copy join request link'}
|
|
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-bold text-white transition-all active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40"
|
|
>
|
|
{copied
|
|
? <Check className="h-3.5 w-3.5 text-[#ccff00]" aria-hidden="true" />
|
|
: <Copy className="h-3.5 w-3.5" aria-hidden="true" />}
|
|
{copied ? 'Copied!' : 'Copy link'}
|
|
</button>
|
|
<button
|
|
onClick={handleDownloadQR}
|
|
disabled={!qrReady}
|
|
aria-label="Download QR code as PNG image"
|
|
className="flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-bold text-white transition-all active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
<Download className="h-3.5 w-3.5" aria-hidden="true" />
|
|
Save QR
|
|
</button>
|
|
<button
|
|
onClick={handleOpenApp}
|
|
disabled={openingApp}
|
|
aria-label="Open nostr-vpn app with this join request"
|
|
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl bg-[#ccff00]/15 hover:bg-[#ccff00]/25 border border-[#ccff00]/30 text-xs font-bold text-[#ccff00] transition-all active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#ccff00]/50 disabled:opacity-60"
|
|
>
|
|
<Network className="h-3.5 w-3.5" aria-hidden="true" />
|
|
{openingApp ? 'Opening…' : 'Open app'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Deep link hint */}
|
|
<p className="text-[10px] text-muted-foreground/40 leading-tight">
|
|
"Open app" requires nostr-vpn installed. If not installed, you'll be redirected to the download page.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="rounded-2xl border border-white/8 bg-white/2 p-6 text-center">
|
|
<Shield className="h-8 w-8 mx-auto mb-3 text-muted-foreground/30" aria-hidden="true" />
|
|
<p className="text-sm font-bold text-white mb-1">Login required</p>
|
|
<p className="text-xs text-muted-foreground mb-4 leading-relaxed">
|
|
Log in with your Nostr keys to generate a personal join request QR for your crew's encrypted mesh.
|
|
</p>
|
|
<LoginArea className="justify-center" />
|
|
</div>
|
|
)}
|
|
|
|
{/* How it works — steps adapt based on login state */}
|
|
<div>
|
|
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 px-1 mb-2">
|
|
How It Works
|
|
</p>
|
|
<ol className="rounded-2xl border border-white/8 bg-white/2 divide-y divide-white/5 overflow-hidden" aria-label="Steps to join a crew network">
|
|
{steps.map(({ n, text }) => (
|
|
<li key={n} className="flex items-center gap-3 px-4 py-3">
|
|
<span
|
|
aria-hidden="true"
|
|
className="w-6 h-6 rounded-full bg-[#ccff00]/15 text-[#ccff00] text-[11px] font-black flex items-center justify-center flex-shrink-0"
|
|
>
|
|
{n}
|
|
</span>
|
|
<p className="text-xs text-muted-foreground leading-snug">{text}</p>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
|
|
{/* Download links — platform-sorted */}
|
|
<div>
|
|
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 px-1 mb-2">
|
|
Get nostr-vpn
|
|
</p>
|
|
<div className="rounded-2xl border border-white/8 overflow-hidden divide-y divide-white/5 bg-white/2" role="list" aria-label="Download options">
|
|
{platforms.map(({ id, label, sublabel, icon: Icon, url, color, bg, badge }) => (
|
|
<a
|
|
key={id}
|
|
href={url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
role="listitem"
|
|
aria-label={`${label} — ${badge}`}
|
|
className="flex items-center gap-3 px-4 py-3 hover:bg-white/5 active:scale-[0.99] transition-all focus-visible:outline-none focus-visible:ring-inset focus-visible:ring-2 focus-visible:ring-white/30"
|
|
>
|
|
<div className={cn('w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0', bg)} aria-hidden="true">
|
|
<Icon className={cn('h-4 w-4', color)} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-bold text-white leading-tight">{label}</p>
|
|
<p className="text-[11px] text-muted-foreground leading-tight mt-0.5">{sublabel}</p>
|
|
</div>
|
|
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-white/5 text-muted-foreground border border-white/10 flex-shrink-0">
|
|
{badge}
|
|
</span>
|
|
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground/40 flex-shrink-0" aria-hidden="true" />
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Use cases */}
|
|
<div className="rounded-2xl border border-white/8 bg-white/2 p-4 space-y-3">
|
|
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50">Why Skaters Use It</p>
|
|
<ul className="grid grid-cols-2 gap-2" aria-label="Use cases">
|
|
{[
|
|
{ emoji: '🔒', label: 'Private crew comms' },
|
|
{ emoji: '📍', label: 'Secret spot sharing' },
|
|
{ emoji: '🌍', label: 'Censorship bypass' },
|
|
{ emoji: '📶', label: 'Offline session mesh' },
|
|
].map(({ emoji, label }) => (
|
|
<li key={label} className="flex items-center gap-2 p-2.5 rounded-xl bg-white/[0.03] border border-white/[0.06]">
|
|
<span className="text-base leading-none" aria-hidden="true">{emoji}</span>
|
|
<p className="text-xs text-white/80 font-medium leading-tight">{label}</p>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
{/* Learn more */}
|
|
<a
|
|
href="https://github.com/mmalmi/nostr-vpn"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
aria-label="nostr-vpn on GitHub — source code, docs, protocol spec (opens in new tab)"
|
|
className="flex items-center justify-between px-4 py-3.5 rounded-2xl border border-white/8 bg-white/2 hover:bg-white/5 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-9 h-9 rounded-xl bg-white/8 flex items-center justify-center flex-shrink-0" aria-hidden="true">
|
|
<Globe className="h-4 w-4 text-muted-foreground" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-bold text-white">nostr-vpn on GitHub</p>
|
|
<p className="text-xs text-muted-foreground">Source code, docs, protocol spec</p>
|
|
</div>
|
|
</div>
|
|
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground/40 flex-shrink-0" aria-hidden="true" />
|
|
</a>
|
|
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Main settings menu ───────────────────────────────────────────────────────
|
|
|
|
function SettingsMenu({ onNavigate }: { onNavigate: (s: Section) => void }) {
|
|
const { user, metadata } = useCurrentUser();
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
{/* Account */}
|
|
<MenuGroup title="Account">
|
|
<MenuRow
|
|
icon={User}
|
|
label="Profile"
|
|
sublabel={metadata?.name ?? (user ? 'Not loaded' : 'Not logged in')}
|
|
iconColor="text-[#ccff00]"
|
|
iconBg="bg-[#ccff00]/15"
|
|
onClick={() => onNavigate('profile')}
|
|
/>
|
|
<MenuRow
|
|
icon={Shield}
|
|
label="Security"
|
|
sublabel="Keys, signing, Amber"
|
|
iconColor="text-red-400"
|
|
iconBg="bg-red-500/15"
|
|
onClick={() => onNavigate('security')}
|
|
/>
|
|
<MenuRow
|
|
icon={User}
|
|
label="NIP-05 Verification"
|
|
sublabel="Get a verified Nostr address"
|
|
iconColor="text-blue-400"
|
|
iconBg="bg-blue-500/15"
|
|
onClick={() => onNavigate('nip05')}
|
|
/>
|
|
</MenuGroup>
|
|
|
|
{/* App */}
|
|
<MenuGroup title="App">
|
|
<MenuRow
|
|
icon={Wallet}
|
|
label="Wallet"
|
|
sublabel="Lightning, Cashu, NWC"
|
|
iconColor="text-orange-400"
|
|
iconBg="bg-orange-500/15"
|
|
onClick={() => onNavigate('wallet')}
|
|
/>
|
|
<MenuRow
|
|
icon={Package}
|
|
label="My Stash"
|
|
sublabel="Private gear inventory"
|
|
iconColor="text-purple-400"
|
|
iconBg="bg-purple-500/15"
|
|
href="/stash"
|
|
/>
|
|
<MenuRow
|
|
icon={Radio}
|
|
label="Relays"
|
|
sublabel="Nostr relay connections"
|
|
iconColor="text-cyan-400"
|
|
iconBg="bg-cyan-500/15"
|
|
onClick={() => onNavigate('relays')}
|
|
/>
|
|
<MenuRow
|
|
icon={Network}
|
|
label="Crew Network"
|
|
sublabel="Private mesh VPN via nostr-vpn"
|
|
iconColor="text-[#ccff00]"
|
|
iconBg="bg-[#ccff00]/10"
|
|
badge="NEW"
|
|
onClick={() => onNavigate('crewnetwork')}
|
|
/>
|
|
<MenuRow
|
|
icon={Languages}
|
|
label="Appearance"
|
|
sublabel="Language and theme"
|
|
iconColor="text-pink-400"
|
|
iconBg="bg-pink-500/15"
|
|
onClick={() => onNavigate('appearance')}
|
|
/>
|
|
</MenuGroup>
|
|
|
|
{/* Data & Advanced */}
|
|
<MenuGroup title="Data">
|
|
<MenuRow
|
|
icon={Database}
|
|
label="My Data"
|
|
sublabel="Export, import, manage"
|
|
iconColor="text-green-400"
|
|
iconBg="bg-green-500/15"
|
|
onClick={() => onNavigate('mydata')}
|
|
/>
|
|
<MenuRow
|
|
icon={Wrench}
|
|
label="Advanced"
|
|
sublabel="Relatr, Sasquatch, tools"
|
|
iconColor="text-gray-400"
|
|
iconBg="bg-white/8"
|
|
onClick={() => onNavigate('advanced')}
|
|
/>
|
|
</MenuGroup>
|
|
|
|
{/* Login area */}
|
|
{!user && (
|
|
<div className="pt-2">
|
|
<LoginArea className="w-full" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
|
|
|
export default function SettingsPage() {
|
|
useSeoMeta({
|
|
title: 'Settings - SkateSpots',
|
|
description: 'Manage your SkateSpots account settings.',
|
|
});
|
|
|
|
const [searchParams] = useSearchParams();
|
|
|
|
// Allow deep-linking into a specific section via ?section=profile etc.
|
|
const initialSection = (searchParams.get('section') as Section) ?? null;
|
|
const [section, setSection] = useState<Section>(initialSection);
|
|
|
|
const handleNavigate = (s: Section) => setSection(s);
|
|
const handleBack = () => setSection(null);
|
|
|
|
const sectionTitles: Record<NonNullable<Section>, string> = {
|
|
profile: 'Edit Profile',
|
|
follows: 'Follows',
|
|
nip05: 'NIP-05 Verification',
|
|
security: 'Security',
|
|
mydata: 'My Data',
|
|
language: 'Language',
|
|
appearance: 'Appearance',
|
|
relays: 'Relays',
|
|
crewnetwork: 'Crew Network',
|
|
wallet: 'Wallet',
|
|
stash: 'My Stash',
|
|
advanced: 'Advanced',
|
|
};
|
|
|
|
return (
|
|
<Layout>
|
|
<div className="container px-4 py-5 max-w-lg mx-auto">
|
|
{/* Header — only shown on the main menu */}
|
|
{!section && (
|
|
<div className="mb-5">
|
|
<h1 className="text-4xl md:text-5xl font-black uppercase leading-none mb-1">
|
|
<span className="text-white">SETTINGS</span>
|
|
</h1>
|
|
<p className="text-muted-foreground text-sm">Account and app preferences</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Sub-screens */}
|
|
{section === 'profile' && <ProfileScreen onBack={handleBack} />}
|
|
{section === 'security' && (
|
|
<div>
|
|
<BackHeader title="Security" onBack={handleBack} />
|
|
<SecuritySettings />
|
|
</div>
|
|
)}
|
|
{section === 'nip05' && <Nip05Screen onBack={handleBack} />}
|
|
{section === 'mydata' && (
|
|
<div>
|
|
<BackHeader title="My Data" onBack={handleBack} />
|
|
<MyDataTab />
|
|
</div>
|
|
)}
|
|
{section === 'relays' && <RelaysScreen onBack={handleBack} />}
|
|
{section === 'crewnetwork' && <CrewNetworkScreen onBack={handleBack} />}
|
|
{section === 'appearance' && <AppearanceScreen onBack={handleBack} />}
|
|
{section === 'wallet' && <WalletScreen onBack={handleBack} />}
|
|
{section === 'advanced' && <AdvancedScreen onBack={handleBack} />}
|
|
|
|
{/* Main menu */}
|
|
{!section && <SettingsMenu onNavigate={handleNavigate} />}
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|