diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 9a069ed..a936e7e 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -3,6 +3,7 @@ import { ScrollToTop } from "./components/ScrollToTop"; import Index from "./pages/Index"; import { Explore } from "./pages/Explore"; +import Dashboard from "./pages/Dashboard"; import { NIP19Page } from "./pages/NIP19Page"; import { Terms } from "./pages/Terms"; import { Privacy } from "./pages/Privacy"; @@ -15,6 +16,7 @@ export function AppRouter() { } /> } /> + } /> } /> } /> {/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */} diff --git a/src/hooks/useUserStats.ts b/src/hooks/useUserStats.ts new file mode 100644 index 0000000..e5d22d7 --- /dev/null +++ b/src/hooks/useUserStats.ts @@ -0,0 +1,53 @@ +import { useQuery } from '@tanstack/react-query'; +import { useNostr } from '@nostrify/react'; + +interface UserStats { + notesCount: number; + followersCount: number; + followingCount: number; + reactionsReceived: number; + totalEvents: number; +} + +export function useUserStats(pubkey: string | undefined) { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['user-stats', pubkey], + queryFn: async (c) => { + if (!pubkey) { + throw new Error('Public key is required'); + } + + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]); + + // Fetch user's events + const [userEvents, followersEvents, followingEvents] = await Promise.all([ + // User's posts (kind 1) + nostr.query([{ kinds: [1], authors: [pubkey], limit: 1000 }], { signal }), + // Followers (contacts that include this user) + nostr.query([{ kinds: [3], '#p': [pubkey], limit: 100 }], { signal }), + // Following (user's contacts) + nostr.query([{ kinds: [3], authors: [pubkey], limit: 1 }], { signal }), + ]); + + // Count following from the contact list + const contactList = followingEvents[0]; + const followingCount = contactList + ? contactList.tags.filter(([tag]) => tag === 'p').length + : 0; + + const stats: UserStats = { + notesCount: userEvents.filter((e) => e.kind === 1).length, + followersCount: followersEvents.length, + followingCount, + reactionsReceived: 0, // Placeholder - would need to query reactions to user's posts + totalEvents: userEvents.length, + }; + + return stats; + }, + enabled: !!pubkey, + staleTime: 1000 * 60 * 5, // 5 minutes + }); +} diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx new file mode 100644 index 0000000..9ac93a5 --- /dev/null +++ b/src/pages/Dashboard.tsx @@ -0,0 +1,390 @@ +import { useSeoMeta } from '@unhead/react'; +import { Link, Outlet, useLocation } from 'react-router-dom'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useAppContext } from '@/hooks/useAppContext'; +import type { NostrMetadata } from '@nostrify/nostrify'; +import type { AppConfig } from '@/contexts/AppContext'; +import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import { + LayoutDashboard, + User, + Settings, + MessageSquare, + Activity, + Server, + ChevronRight, + ExternalLink +} from 'lucide-react'; +import { LoginArea } from '@/components/auth/LoginArea'; +import { genUserName } from '@/lib/genUserName'; +import { nip19 } from 'nostr-tools'; + +const DashboardPage = () => { + const location = useLocation(); + const { user } = useCurrentUser(); + const { config } = useAppContext(); + const author = useAuthor(user?.pubkey ?? ''); + const metadata = author.data?.metadata; + + useSeoMeta({ + title: 'Dashboard - LAYER.systems', + description: 'Your personal Nostr dashboard', + }); + + const displayName = metadata?.display_name ?? metadata?.name ?? (user ? genUserName(user.pubkey) : 'Anonymous'); + const profileImage = metadata?.picture; + + const sidebarItems = [ + { path: '/dashboard', icon: LayoutDashboard, label: 'Overview' }, + { path: '/dashboard/profile', icon: User, label: 'Profile' }, + { path: '/dashboard/messages', icon: MessageSquare, label: 'Messages' }, + { path: '/dashboard/relays', icon: Server, label: 'Relays' }, + { path: '/dashboard/settings', icon: Settings, label: 'Settings' }, + ]; + + if (!user) { + return ( +
+
+ + +
+ +
+ Access Dashboard +

+ Please log in to view your personal dashboard +

+
+ + + +
+
+
+ ); + } + + const isExactPath = location.pathname === '/dashboard'; + + return ( +
+ {/* Header */} +
+
+ +
+ L +
+ LAYER.systems + + +
+ + + + +
+
+
+ +
+
+ {/* Sidebar */} + + + {/* Main Content */} +
+ {isExactPath ? ( + + ) : ( + + )} +
+
+
+
+ ); +}; + +interface DashboardOverviewProps { + user: { pubkey: string; signer: unknown }; + metadata: NostrMetadata | undefined; + config: AppConfig; +} + +const DashboardOverview = ({ user, metadata, config }: DashboardOverviewProps) => { + const displayName = metadata?.display_name ?? metadata?.name ?? genUserName(user.pubkey); + const npub = nip19.npubEncode(user.pubkey); + const relays = config.relayMetadata?.relays ?? []; + const readRelays = relays.filter((r) => r.read); + const writeRelays = relays.filter((r) => r.write); + + return ( +
+ {/* Welcome Header */} +
+
+
+
+ +
+

+ Welcome back, {displayName}! 👋 +

+

+ Here's an overview of your Nostr identity and connected relays +

+
+
+ + {/* Stats Grid */} +
+ + +
+
+

Total Relays

+

{relays.length}

+
+
+ +
+
+
+
+ + + +
+
+

Read Relays

+

{readRelays.length}

+
+
+ +
+
+
+
+ + + +
+
+

Write Relays

+

{writeRelays.length}

+
+
+ +
+
+
+
+
+ + {/* Personal Data Section */} +
+ {/* Profile Information */} + + + + + Profile Information + + + + + + + + + + + + + {/* Identity & Keys */} + + + + + Identity & Keys + + + +
+

Public Key (npub)

+
+ + {npub} + + +
+
+ +
+

Hex Public Key

+
+ + {user.pubkey} + + +
+
+ + + + +
+
+
+ + {/* Connected Relays */} + + + + + Connected Relays + + + + {relays.length === 0 ? ( +

No relays configured

+ ) : ( +
+ {relays.map((relay: { url: string; read: boolean; write: boolean }, index: number) => ( +
+
+
+ {relay.url} +
+
+ {relay.read && ( + + Read + + )} + {relay.write && ( + + Write + + )} +
+
+ ))} +
+ )} + + +
+ ); +}; + +interface DataRowProps { + label: string; + value: string; + multiline?: boolean; + link?: boolean; +} + +const DataRow = ({ label, value, multiline, link }: DataRowProps) => ( +
+

{label}

+ {link && value !== 'Not set' ? ( + + {value} + + + ) : ( +

{value}

+ )} +
+); + +export default DashboardPage;