mirror of
https://github.com/layer-systems/website.git
synced 2026-06-17 01:58:30 +02:00
feat: add Dashboard page with user statistics and recent activity components
This commit is contained in:
@@ -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 */}
|
||||
|
||||
116
src/components/dashboard/DashboardStats.tsx
Normal file
116
src/components/dashboard/DashboardStats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
src/components/dashboard/EventKindsChart.tsx
Normal file
99
src/components/dashboard/EventKindsChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
src/components/dashboard/RecentActivityList.tsx
Normal file
132
src/components/dashboard/RecentActivityList.tsx
Normal 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
|
||||
});
|
||||
}
|
||||
63
src/components/navigation/AppSidebar.tsx
Normal file
63
src/components/navigation/AppSidebar.tsx
Normal 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
64
src/hooks/useUserStats.ts
Normal 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
64
src/pages/Dashboard.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user