diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 9a069ed..609b18a 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/components/dashboard/DashboardStats.tsx b/src/components/dashboard/DashboardStats.tsx new file mode 100644 index 0000000..e2ef697 --- /dev/null +++ b/src/components/dashboard/DashboardStats.tsx @@ -0,0 +1,116 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { FileText, Hash, Activity, Clock } from 'lucide-react'; +import { useUserStats } from '@/hooks/useUserStats'; + +interface DashboardStatsProps { + pubkey: string; +} + +export function DashboardStats({ pubkey }: DashboardStatsProps) { + const { data: stats, isLoading } = useUserStats(pubkey); + + if (isLoading) { + return ( +
+ {[...Array(4)].map((_, i) => ( + + + + + + + + + + + ))} +
+ ); + } + + if (!stats) { + return null; + } + + const lastActivityDate = stats.lastActivity + ? new Date(stats.lastActivity * 1000).toLocaleDateString() + : 'Never'; + + const uniqueKinds = Object.keys(stats.eventsByKind).length; + + return ( +
+ + + Total Events + + + +
{stats.totalEvents}
+

+ All events published +

+
+
+ + + + Event Types + + + +
{uniqueKinds}
+

+ Different kinds used +

+
+
+ + + + Most Used + + + +
+ {Object.entries(stats.eventsByKind).length > 0 + ? `Kind ${ + Object.entries(stats.eventsByKind).sort( + ([, a], [, b]) => b - a + )[0][0] + }` + : 'N/A'} +
+

+ {Object.entries(stats.eventsByKind).length > 0 + ? `${ + Object.entries(stats.eventsByKind).sort( + ([, a], [, b]) => b - a + )[0][1] + } events` + : 'No events yet'} +

+
+
+ + + + Last Activity + + + +
+ {stats.lastActivity + ? new Date(stats.lastActivity * 1000).toLocaleDateString( + undefined, + { month: 'short', day: 'numeric' } + ) + : 'Never'} +
+

{lastActivityDate}

+
+
+
+ ); +} diff --git a/src/components/dashboard/EventKindsChart.tsx b/src/components/dashboard/EventKindsChart.tsx new file mode 100644 index 0000000..40cc2a7 --- /dev/null +++ b/src/components/dashboard/EventKindsChart.tsx @@ -0,0 +1,99 @@ +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'; +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts'; +import { useUserStats } from '@/hooks/useUserStats'; +import { Skeleton } from '@/components/ui/skeleton'; + +interface EventKindsChartProps { + pubkey: string; +} + +// Map of common kind numbers to their names +const kindNames: Record = { + 0: 'Metadata', + 1: 'Text Note', + 3: 'Contacts', + 4: 'DM', + 5: 'Deletion', + 6: 'Repost', + 7: 'Reaction', + 9735: 'Zap', + 10002: 'Relay List', + 30023: 'Article', +}; + +export function EventKindsChart({ pubkey }: EventKindsChartProps) { + const { data: stats, isLoading } = useUserStats(pubkey); + + if (isLoading) { + return ( + + + + + + + + + + ); + } + + if (!stats || Object.keys(stats.eventsByKind).length === 0) { + return ( + + + Event Distribution + Events published by kind + + +
+ No events found +
+
+
+ ); + } + + // Transform data for chart + const chartData = Object.entries(stats.eventsByKind) + .sort(([, a], [, b]) => b - a) + .slice(0, 10) // Top 10 kinds + .map(([kind, count]) => ({ + kind: kindNames[Number(kind)] || `Kind ${kind}`, + count, + })); + + const chartConfig = { + count: { + label: 'Events', + color: 'hsl(var(--chart-1))', + }, + }; + + return ( + + + Event Distribution + Top event types you've published + + + + + + + + } /> + + + + + + ); +} diff --git a/src/components/dashboard/RecentActivityList.tsx b/src/components/dashboard/RecentActivityList.tsx new file mode 100644 index 0000000..f352dd8 --- /dev/null +++ b/src/components/dashboard/RecentActivityList.tsx @@ -0,0 +1,132 @@ +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useUserStats } from '@/hooks/useUserStats'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +interface RecentActivityListProps { + pubkey: string; +} + +// Map of common kind numbers to their names +const kindNames: Record = { + 0: 'Metadata', + 1: 'Text Note', + 3: 'Contacts', + 4: 'DM', + 5: 'Deletion', + 6: 'Repost', + 7: 'Reaction', + 9735: 'Zap', + 10002: 'Relay List', + 30023: 'Article', +}; + +export function RecentActivityList({ pubkey }: RecentActivityListProps) { + const { data: stats, isLoading } = useUserStats(pubkey); + + if (isLoading) { + return ( + + + + + + +
+ {[...Array(5)].map((_, i) => ( +
+
+ + +
+ +
+ ))} +
+
+
+ ); + } + + if (!stats || stats.recentEvents.length === 0) { + return ( + + + Recent Activity + Your latest events + + +
+ No recent activity +
+
+
+ ); + } + + return ( + + + Recent Activity + Your latest {stats.recentEvents.length} events + + + +
+ {stats.recentEvents.map((event) => { + const kindName = kindNames[event.kind] || `Kind ${event.kind}`; + const timestamp = new Date(event.created_at * 1000); + const relativeTime = getRelativeTime(timestamp); + + // Get preview of content + const contentPreview = event.content + ? event.content.slice(0, 100) + (event.content.length > 100 ? '...' : '') + : 'No content'; + + return ( +
+
+
+ + {kindName} + + + {relativeTime} + +
+

+ {contentPreview} +

+
+
+ ); + })} +
+
+
+
+ ); +} + +function getRelativeTime(date: Date): string { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined + }); +} diff --git a/src/components/navigation/AppSidebar.tsx b/src/components/navigation/AppSidebar.tsx new file mode 100644 index 0000000..f0beb0e --- /dev/null +++ b/src/components/navigation/AppSidebar.tsx @@ -0,0 +1,63 @@ +import { Home, LayoutDashboard } from 'lucide-react'; +import { Link, useLocation } from 'react-router-dom'; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from '@/components/ui/sidebar'; +import { LoginArea } from '@/components/auth/LoginArea'; + +const navigationItems = [ + { + title: 'Home', + url: '/', + icon: Home, + }, + { + title: 'Dashboard', + url: '/dashboard', + icon: LayoutDashboard, + }, +]; + +export function AppSidebar() { + const location = useLocation(); + + return ( + + + + Navigation + + + {navigationItems.map((item) => { + const isActive = location.pathname === item.url; + return ( + + + + + {item.title} + + + + ); + })} + + + + + +
+ +
+
+
+ ); +} diff --git a/src/hooks/useUserStats.ts b/src/hooks/useUserStats.ts new file mode 100644 index 0000000..96eeffa --- /dev/null +++ b/src/hooks/useUserStats.ts @@ -0,0 +1,64 @@ +import { useQuery } from '@tanstack/react-query'; +import { useNostr } from '@nostrify/react'; +import type { NostrEvent } from '@nostrify/nostrify'; + +export interface UserStats { + totalEvents: number; + eventsByKind: Record; + recentEvents: NostrEvent[]; + lastActivity?: number; +} + +export function useUserStats(pubkey: string | undefined) { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['user-stats', pubkey], + queryFn: async (c) => { + if (!pubkey) throw new Error('No pubkey provided'); + + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]); + + // Query user's events (limited to 500 for performance) + const events = await nostr.query( + [ + { + authors: [pubkey], + limit: 500, + }, + ], + { signal } + ); + + // Calculate statistics + const eventsByKind: Record = {}; + let lastActivity = 0; + + for (const event of events) { + // Count by kind + eventsByKind[event.kind] = (eventsByKind[event.kind] || 0) + 1; + + // Track most recent activity + if (event.created_at > lastActivity) { + lastActivity = event.created_at; + } + } + + // Get most recent 10 events + const recentEvents = [...events] + .sort((a, b) => b.created_at - a.created_at) + .slice(0, 10); + + const stats: UserStats = { + totalEvents: events.length, + eventsByKind, + recentEvents, + lastActivity: lastActivity > 0 ? lastActivity : undefined, + }; + + return stats; + }, + enabled: !!pubkey, + staleTime: 30000, // Cache for 30 seconds + }); +} diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx new file mode 100644 index 0000000..056f3a7 --- /dev/null +++ b/src/pages/Dashboard.tsx @@ -0,0 +1,64 @@ +import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'; +import { AppSidebar } from '@/components/navigation/AppSidebar'; +import { DashboardStats } from '@/components/dashboard/DashboardStats'; +import { EventKindsChart } from '@/components/dashboard/EventKindsChart'; +import { RecentActivityList } from '@/components/dashboard/RecentActivityList'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { Card, CardContent } from '@/components/ui/card'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { InfoIcon } from 'lucide-react'; + +export function Dashboard() { + const { user } = useCurrentUser(); + + return ( + +
+ +
+
+ +

Dashboard

+
+ +
+ {!user ? ( + + +
+ + + + Please log in to view your dashboard and activity statistics. + + +
+
+
+ ) : ( + <> +
+

+ Welcome back! +

+

+ Here's an overview of your Nostr activity and statistics. +

+
+ + + +
+ + +
+ + )} +
+
+
+
+ ); +} + +export default Dashboard;