From 1ce2837fef31b91bbecc54aed8e4d4b13abfc48f Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 23 Jul 2026 19:32:38 +0700 Subject: [PATCH] settings: replace 7-tab horizontal scroll with iOS-style vertical menu + sub-screens Co-authored-by: shakespeare.diy --- src/pages/SettingsPage.tsx | 1006 ++++++++++++++++-------------------- 1 file changed, 432 insertions(+), 574 deletions(-) diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index d979155..667abbf 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -14,15 +14,12 @@ import { useQueryClient } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import { NSchema as n } from '@nostrify/nostrify'; import { useState } from 'react'; - import { LoginArea } from '@/components/auth/LoginArea'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAppContext } from '@/hooks/useAppContext'; import { toast } from '@/hooks/useToast'; - +import { cn } from '@/lib/utils'; import { User, Palette, @@ -39,665 +36,526 @@ import { Database, Package, ChevronRight, + ChevronLeft, } from 'lucide-react'; -// Fallback relays for fetching profile data -const PROFILE_FALLBACK_RELAYS = [ - 'wss://purplepag.es', - 'wss://relay.damus.io', - 'wss://nos.lol', -]; +// ─── Types ──────────────────────────────────────────────────────────────────── -function ProfileTab() { +type Section = + | null + | 'profile' + | 'follows' + | 'nip05' + | 'security' + | 'mydata' + | 'language' + | 'appearance' + | 'relays' + | 'wallet' + | 'stash' + | 'advanced'; + +// ─── Back header ────────────────────────────────────────────────────────────── + +function BackHeader({ title, onBack }: { title: string; onBack: () => void }) { + return ( +
+ +

{title}

+
+ ); +} + +// ─── 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 = ( +
+
+ +
+
+

{label}

+ {sublabel &&

{sublabel}

} +
+ {badge && ( + + {badge} + + )} + +
+ ); + + if (href) { + return {inner}; + } + + return ; +} + +// ─── Menu group ─────────────────────────────────────────────────────────────── + +function MenuGroup({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+
+ {children} +
+
+ ); +} + +// ─── 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 handleRefreshProfile = async () => { + const handleRefresh = async () => { if (!user) return; - setIsRefreshing(true); - console.log('[ProfileTab] Manual refresh for pubkey:', user.pubkey.slice(0, 8) + '...'); - try { - // First invalidate the cache queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] }); - - // Try main relays - let events = await nostr.query( - [{ kinds: [0], authors: [user.pubkey], limit: 1 }], - { signal: AbortSignal.timeout(8000) } - ); - - console.log('[ProfileTab] Main relays returned', events.length, 'events'); - - // If not found, try fallback relays + let events = await nostr.query([{ kinds: [0], authors: [user.pubkey], limit: 1 }], { signal: AbortSignal.timeout(8000) }); if (events.length === 0) { - console.log('[ProfileTab] Trying fallback relays...'); - const fallbackGroup = nostr.group(PROFILE_FALLBACK_RELAYS); - events = await fallbackGroup.query( - [{ kinds: [0], authors: [user.pubkey], limit: 1 }], - { signal: AbortSignal.timeout(8000) } - ); - console.log('[ProfileTab] Fallback relays returned', events.length, 'events'); + 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 event = events[0]; - try { - const profileMetadata = n.json().pipe(n.metadata()).parse(event.content); - queryClient.setQueryData(['author', user.pubkey], { metadata: profileMetadata, event }); - toast({ - title: 'Profile refreshed!', - description: `Found profile: ${profileMetadata.name || 'unnamed'}`, - }); - } catch { - toast({ - title: 'Profile found', - description: 'But metadata could not be parsed.', - variant: 'destructive', - }); - } + 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: 'Your profile (kind 0 event) was not found on any relay. You may need to publish it.', - variant: 'destructive', - }); + toast({ title: 'Profile not found', description: 'Not found on any relay.', variant: 'destructive' }); } - } catch (error) { - console.error('[ProfileTab] Refresh failed:', error); - toast({ - title: 'Refresh failed', - description: 'Could not connect to relays. Check your internet connection.', - variant: 'destructive', - }); + } catch { + toast({ title: 'Refresh failed', description: 'Could not connect to relays.', variant: 'destructive' }); } finally { setIsRefreshing(false); } }; - if (!user) { - return ( - - - -

Login Required

-

- Log in to edit your profile. -

- -
-
- ); - } - return ( -
- {/* Profile sync status - show if no metadata found */} - {!metadata?.name && ( - - -
- -
-

- Profile not loaded. Your profile data wasn't found on the configured relays. - Try refreshing or make sure you've published your profile. -

-

- Logged in as: {user.pubkey} -

-
-
-
-
+ )} + +
)} - - - - {/* Following & Followers */} - - - {/* NIP-05 Providers Info */} - - - NIP-05 Verification Providers - - Get a verified Nostr address (like email@domain.com) to build trust. Required for creating events, coaching, and shops. - - - - - -
-

💡 Tip: After getting your NIP-05, add it to your profile in the "NIP-05" field above and save.

-
-
-
); } -function AppearanceTab() { - const { t } = useTranslation(); +// ─── 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 ( -
- {/* Language Settings */} - - - - - {t('settings.language')} - - - {t('settings.selectLanguage')} - - - - - - - - {/* Theme Settings */} - - - - - {t('settings.appearance')} - - - SkateSpots uses a dark neon aesthetic inspired by skate culture. - - - -
-
🌙
-

Dark Mode Only

-

- SkateSpots is designed with a dark neon aesthetic inspired by Tony Hawk's Underground 2 and skate culture. - The black background with neon pink, lime, and orange creates the signature skateboarding vibe. -

-
- -
- 💡 Embrace the darkness - that's where skaters thrive -
-
-
+
+ +
+

+ Get a verified Nostr address (like name@domain.com) to build trust. Required for creating events, coaching profiles, and shops. +

+ +
+ After getting your NIP-05, add it in the Profile field and save. +
+
); } -function RelaysTab() { +// ─── Relays sub-screen ──────────────────────────────────────────────────────── + +function RelaysScreen({ onBack }: { onBack: () => void }) { const { user } = useCurrentUser(); const { config, updateConfig } = useAppContext(); const useSkateRelay = config.useSkateRelay ?? true; - const handleToggleCommunityRelay = () => { - updateConfig((current) => ({ - ...current, - useSkateRelay: !useSkateRelay, - })); + const handleToggle = () => { + updateConfig((current) => ({ ...current, useSkateRelay: !useSkateRelay })); toast({ title: useSkateRelay ? 'Community relay disabled' : 'Community relay enabled', - description: useSkateRelay - ? 'You will now only use your configured relays for skate content.' - : 'The SkateSpots community relay will be included for skate content.', + description: useSkateRelay ? 'Using only your configured relays.' : 'SkateSpots relay included.', }); }; return ( -
- {/* Community Relay Setting - Only shown when logged in */} - {user && ( - - - - 🛹 Community Relay - - - Control whether to include the SkateSpots community relay for skate-specific content. - - - -
-
-

Use Community Relay

-

- {useSkateRelay - ? 'Enabled - Your content syncs with the global skate community' - : 'Disabled - Using only your personal relays'} -

-
- -
- -
-

- 💡 What does this do? -

-
    -
  • Enabled (default): Your spots, events, and profiles sync with the global SkateSpots community
  • -
  • Disabled: Content only goes to your personal relays - you're fully decentralized but may not see all community content
  • -
-
- - {!useSkateRelay && ( -
-

- ⚠️ Note: With the community relay disabled, you'll only see skate content from relays you've configured below. Make sure you have at least one relay set up! -

-
- )} -
-
- )} - - - - - - Nostr Relays - - - Manage which relays you connect to for sending and receiving data. - - - - - - - - {/* Default Relays Explanation */} - - - Default Relays - - -
- {/* Community Relay */} -
-
-
🛹
-
-

goskatespots.nostr1.com

-

wss://goskatespots.nostr1.com

-
-
-

- The dedicated SkateSpots community relay. Custom policies for spots, events, coaches, filmers, and shops. Toggle it on/off above. -

-
-

- ⚠️ Allowlisted: Your npub must be approved to publish here. DM the relay owner to request access. -

-
-
- - {/* Auth relay */} -
-
-
🔐
-
-

auth.nostr1.com

-

wss://auth.nostr1.com

-
-
-

- Authentication relay for Nostr identity verification and NIP-42 auth flows. -

-
- - {/* WoTR relay */} -
-
-
🛡️
-
-

wotr.relatr.xyz

-

wss://wotr.relatr.xyz

-
-
-

- Relatr's Web of Trust relay. Powers trust scores and broader Nostr network connectivity. -

-
- - {/* Ditto relay */} -
-
-
🌐
-
-

relay.ditto.pub

-

wss://relay.ditto.pub

-
-
-

- General purpose relay with broad Nostr network coverage for maximum reach. -

-
- - {/* Profile Aggregator */} -
-
-
👤
-
-

purplepag.es

-

wss://purplepag.es

- Read only -
-
-

- Profile aggregator that indexes Nostr profiles from across the network. Used for fast profile lookups and discovery. +

+ +
+ {user && ( +
+
+

Community Relay

+

+ {useSkateRelay ? 'Syncing with global skate community' : 'Using only personal relays'}

+
- -

- Your content publishes to all write-enabled relays for maximum reliability. You can customise your relay list above using the relay manager. -

- -
-

- 💡 Want to run your own relay? Check out{' '} - - relay.tools - - {' '}to easily spin up and manage your own Nostr relay! -

-
- - + )} + +
); } -function WalletTab() { - const { user } = useCurrentUser(); - - if (!user) { - return ( - - - -

Login Required

-

- Log in to manage your wallet. -

- -
-
- ); - } +// ─── Appearance sub-screen ──────────────────────────────────────────────────── +function AppearanceScreen({ onBack }: { onBack: () => void }) { + const { t } = useTranslation(); return ( -
- {/* Redirect to Wallet Page */} - - - - - Your Wallet - - - Manage your Cashu wallet and Lightning connections from the dedicated wallet page. - - - - - - - - - - {/* My Stash — gear inventory */} - - - -
-
- -
-
-

My Stash

-

- Private encrypted gear inventory — track decks, trucks, shoes, and more. -

-
- -
-
-
- - - {/* NWC Quick Connect */} - - - - - Connect External Wallet (NWC) - - - Connect your existing Lightning wallet using Nostr Wallet Connect for one-click zaps. - - - - - - - -
-

- What is NWC? Nostr Wallet Connect lets you link your Lightning wallet (like Alby, Minibits, Zeus, or Coinos) to send zaps without copy-pasting invoices. -

+
+ +
+
+

Language

+
+
- - +
+
+
🌙
+

Dark Mode Only

+

+ SkateSpots runs a dark neon aesthetic inspired by skate culture. Black background, neon pink, lime, and orange. +

+
+
); } -function AdvancedTab() { +// ─── Advanced sub-screen ────────────────────────────────────────────────────── + +function AdvancedScreen({ onBack }: { onBack: () => void }) { const handleResetSasquatch = () => { - // Clear minimized state so Sasquatch appears expanded localStorage.removeItem('sasquatch-minimized'); - - // Show confirmation and reload alert('The Sasquatch has been expanded! 🦧 Refresh the page to see him.'); window.location.reload(); }; return ( -
- {/* Relatr Integration */} - - - {/* Sasquatch Guide */} - - - - 🦧 The Sasquatch Guide - - - The Sasquatch is your onboarding guide - always available on home and onboarding pages. - - - -
-

- The Sasquatch appears as a minimizable button in the bottom-left corner. Click to expand or minimize at any time. - If you want to reset it to expanded view, use the button below. -

- +
- -
- 💡 The Sasquatch is always available - just look for the button in the bottom-left corner! -
-
-
+
+
); } +// ─── Wallet sub-screen ──────────────────────────────────────────────────────── + +function WalletScreen({ onBack }: { onBack: () => void }) { + const { user } = useCurrentUser(); + return ( +
+ + {!user ? ( +
+ +

Log in to manage your wallet

+ +
+ ) : ( +
+ +
+
+ +
+
+

Open Wallet

+

Balance, send, receive

+
+
+ + + + + + +
+ )} +
+ ); +} + +// ─── Main settings menu ─────────────────────────────────────────────────────── + +function SettingsMenu({ onNavigate }: { onNavigate: (s: Section) => void }) { + const { user, metadata } = useCurrentUser(); + + return ( +
+ {/* Account */} + + onNavigate('profile')} + /> + onNavigate('security')} + /> + onNavigate('nip05')} + /> + + + {/* App */} + + onNavigate('wallet')} + /> + + onNavigate('relays')} + /> + onNavigate('appearance')} + /> + + + {/* Data & Advanced */} + + onNavigate('mydata')} + /> + onNavigate('advanced')} + /> + + + {/* Login area */} + {!user && ( +
+ +
+ )} +
+ ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + export default function SettingsPage() { useSeoMeta({ title: 'Settings - SkateSpots', description: 'Manage your SkateSpots account settings.', }); - // Check URL params for tab selection - const urlParams = new URLSearchParams(window.location.search); - const initialTab = urlParams.get('tab') || 'profile'; + const [section, setSection] = useState
(null); + + const handleNavigate = (s: Section) => setSection(s); + const handleBack = () => setSection(null); + + const sectionTitles: Record, string> = { + profile: 'Edit Profile', + follows: 'Follows', + nip05: 'NIP-05 Verification', + security: 'Security', + mydata: 'My Data', + language: 'Language', + appearance: 'Appearance', + relays: 'Relays', + wallet: 'Wallet', + stash: 'My Stash', + advanced: 'Advanced', + }; return ( -
-
-

- - Settings -

-

- Manage your account and app preferences -

-
- - - {/* Horizontally scrollable tab bar on mobile — no wrapping */} -
- - - - Profile - - - - My Data - - - - Security - - - - Theme - - - - Relays - - - - Wallet & Stash - - - - Advanced - - +
+ {/* Header — only shown on the main menu */} + {!section && ( +
+

+ SETTINGS +

+

Account and app preferences

+ )} - - - - - - - - - + {/* Sub-screens */} + {section === 'profile' && } + {section === 'security' && ( +
+ - +
+ )} + {section === 'nip05' && } + {section === 'mydata' && ( +
+ + +
+ )} + {section === 'relays' && } + {section === 'appearance' && } + {section === 'wallet' && } + {section === 'advanced' && } - - - - - - - - - - - - - - - - + {/* Main menu */} + {!section && }
);