feat: add Dashboard page with user statistics and recent activity components

This commit is contained in:
2025-12-28 13:54:11 +01:00
parent 007725dd7a
commit 098626fbea
7 changed files with 540 additions and 0 deletions

View File

@@ -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() {
<Routes>
<Route path="/" element={<Index />} />
<Route path="/explore" element={<Explore />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/terms" element={<Terms />} />
<Route path="/privacy" element={<Privacy />} />
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}

View File

@@ -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 (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-4 rounded" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16 mb-1" />
<Skeleton className="h-3 w-32" />
</CardContent>
</Card>
))}
</div>
);
}
if (!stats) {
return null;
}
const lastActivityDate = stats.lastActivity
? new Date(stats.lastActivity * 1000).toLocaleDateString()
: 'Never';
const uniqueKinds = Object.keys(stats.eventsByKind).length;
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Events</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalEvents}</div>
<p className="text-xs text-muted-foreground">
All events published
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Event Types</CardTitle>
<Hash className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{uniqueKinds}</div>
<p className="text-xs text-muted-foreground">
Different kinds used
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Most Used</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{Object.entries(stats.eventsByKind).length > 0
? `Kind ${
Object.entries(stats.eventsByKind).sort(
([, a], [, b]) => b - a
)[0][0]
}`
: 'N/A'}
</div>
<p className="text-xs text-muted-foreground">
{Object.entries(stats.eventsByKind).length > 0
? `${
Object.entries(stats.eventsByKind).sort(
([, a], [, b]) => b - a
)[0][1]
} events`
: 'No events yet'}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Last Activity</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.lastActivity
? new Date(stats.lastActivity * 1000).toLocaleDateString(
undefined,
{ month: 'short', day: 'numeric' }
)
: 'Never'}
</div>
<p className="text-xs text-muted-foreground">{lastActivityDate}</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -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<number, string> = {
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 (
<Card>
<CardHeader>
<Skeleton className="h-6 w-40 mb-2" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent>
<Skeleton className="h-[300px] w-full" />
</CardContent>
</Card>
);
}
if (!stats || Object.keys(stats.eventsByKind).length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Event Distribution</CardTitle>
<CardDescription>Events published by kind</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
No events found
</div>
</CardContent>
</Card>
);
}
// 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 (
<Card>
<CardHeader>
<CardTitle>Event Distribution</CardTitle>
<CardDescription>Top event types you've published</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[300px] w-full">
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="kind"
angle={-45}
textAnchor="end"
height={80}
className="text-xs"
/>
<YAxis className="text-xs" />
<ChartTooltip content={<ChartTooltipContent />} />
<Bar dataKey="count" fill="var(--color-count)" radius={[4, 4, 0, 0]} />
</BarChart>
</ChartContainer>
</CardContent>
</Card>
);
}

View File

@@ -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<number, string> = {
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 (
<Card>
<CardHeader>
<Skeleton className="h-6 w-32 mb-2" />
<Skeleton className="h-4 w-48" />
</CardHeader>
<CardContent>
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-start justify-between">
<div className="space-y-1 flex-1">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-full max-w-sm" />
</div>
<Skeleton className="h-5 w-16" />
</div>
))}
</div>
</CardContent>
</Card>
);
}
if (!stats || stats.recentEvents.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>Your latest events</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center h-[200px] text-muted-foreground">
No recent activity
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>Your latest {stats.recentEvents.length} events</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-[400px] pr-4">
<div className="space-y-4">
{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 (
<div
key={event.id}
className="flex items-start justify-between border-b pb-3 last:border-0"
>
<div className="space-y-1 flex-1 min-w-0">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
{kindName}
</Badge>
<span className="text-xs text-muted-foreground">
{relativeTime}
</span>
</div>
<p className="text-sm text-muted-foreground line-clamp-2">
{contentPreview}
</p>
</div>
</div>
);
})}
</div>
</ScrollArea>
</CardContent>
</Card>
);
}
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
});
}

View File

@@ -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 (
<Sidebar>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{navigationItems.map((item) => {
const isActive = location.pathname === item.url;
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild isActive={isActive}>
<Link to={item.url}>
<item.icon />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<div className="p-2">
<LoginArea className="w-full" />
</div>
</SidebarFooter>
</Sidebar>
);
}

64
src/hooks/useUserStats.ts Normal file
View File

@@ -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<number, number>;
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<number, number> = {};
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
});
}

64
src/pages/Dashboard.tsx Normal file
View File

@@ -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 (
<SidebarProvider>
<div className="flex min-h-screen w-full">
<AppSidebar />
<main className="flex-1">
<div className="sticky top-0 z-10 flex h-14 items-center gap-4 border-b bg-background px-4 lg:h-[60px] lg:px-6">
<SidebarTrigger />
<h1 className="text-lg font-semibold md:text-xl">Dashboard</h1>
</div>
<div className="flex-1 space-y-6 p-4 md:p-6 lg:p-8">
{!user ? (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center">
<div className="max-w-sm mx-auto space-y-4">
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertDescription>
Please log in to view your dashboard and activity statistics.
</AlertDescription>
</Alert>
</div>
</CardContent>
</Card>
) : (
<>
<div className="space-y-2">
<h2 className="text-3xl font-bold tracking-tight">
Welcome back!
</h2>
<p className="text-muted-foreground">
Here's an overview of your Nostr activity and statistics.
</p>
</div>
<DashboardStats pubkey={user.pubkey} />
<div className="grid gap-6 md:grid-cols-2">
<EventKindsChart pubkey={user.pubkey} />
<RecentActivityList pubkey={user.pubkey} />
</div>
</>
)}
</div>
</main>
</div>
</SidebarProvider>
);
}
export default Dashboard;