Add dashboard page with collapsible sidebar and user stats

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-12-28 12:54:17 +00:00
parent 9b39e9ed58
commit 29bd0bb95e
8 changed files with 451 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,25 @@
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { DashboardSidebar } from "./DashboardSidebar";
interface DashboardLayoutProps {
children: React.ReactNode;
}
export function DashboardLayout({ children }: DashboardLayoutProps) {
return (
<SidebarProvider>
<div className="flex min-h-screen w-full">
<DashboardSidebar />
<main className="flex-1 flex flex-col">
<header className="sticky top-0 z-10 flex h-16 items-center gap-4 border-b bg-background px-4 md:px-6">
<SidebarTrigger />
<h1 className="text-lg font-semibold">Dashboard</h1>
</header>
<div className="flex-1 overflow-auto p-4 md:p-6">
{children}
</div>
</main>
</div>
</SidebarProvider>
);
}

View File

@@ -0,0 +1,160 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { useUserStats } from "@/hooks/useUserStats";
import { DashboardStatCard } from "./DashboardStatCard";
import { FileText, Heart, Repeat2, TrendingUp } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
export function DashboardOverview() {
const { user } = useCurrentUser();
const { data: stats, isLoading, error } = useUserStats(user?.pubkey);
if (isLoading) {
return (
<div className="space-y-6">
<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-2" />
<Skeleton className="h-3 w-32" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<Skeleton className="h-6 w-48 mb-2" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent>
<Skeleton className="h-64 w-full" />
</CardContent>
</Card>
</div>
);
}
if (error) {
return (
<Card>
<CardContent className="py-12 px-8 text-center">
<p className="text-muted-foreground">
Failed to load statistics. Please try again later.
</p>
</CardContent>
</Card>
);
}
if (!stats) {
return (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center">
<p className="text-muted-foreground">
No data available. Start posting to see your statistics!
</p>
</CardContent>
</Card>
);
}
const chartConfig = {
posts: {
label: "Posts",
color: "hsl(var(--primary))",
},
};
return (
<div className="space-y-6">
{/* Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<DashboardStatCard
title="Total Posts"
value={stats.totalPosts}
description="All time"
icon={FileText}
/>
<DashboardStatCard
title="Reactions"
value={stats.totalReactions}
description="All time"
icon={Heart}
/>
<DashboardStatCard
title="Reposts"
value={stats.totalReposts}
description="All time"
icon={Repeat2}
/>
<DashboardStatCard
title="Posts This Week"
value={stats.postsThisWeek}
description="Last 7 days"
icon={TrendingUp}
/>
</div>
{/* Activity Chart */}
<Card>
<CardHeader>
<CardTitle>Activity Overview</CardTitle>
<CardDescription>Your posting activity over the last 30 days</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-64 w-full">
<AreaChart data={stats.dailyActivity}>
<defs>
<linearGradient id="fillPosts" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-posts)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-posts)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => value}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
allowDecimals={false}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="line" />}
/>
<Area
dataKey="posts"
type="monotone"
fill="url(#fillPosts)"
fillOpacity={0.4}
stroke="var(--color-posts)"
strokeWidth={2}
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
</div>
);
}

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 menuItems = [
{
title: "Home",
url: "/",
icon: Home,
},
{
title: "Dashboard",
url: "/dashboard",
icon: LayoutDashboard,
},
];
export function DashboardSidebar() {
const location = useLocation();
return (
<Sidebar collapsible="icon">
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{menuItems.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
isActive={location.pathname === item.url}
>
<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>
);
}

View File

@@ -0,0 +1,44 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LucideIcon } from "lucide-react";
interface DashboardStatCardProps {
title: string;
value: string | number;
description?: string;
icon: LucideIcon;
trend?: {
value: number;
label: string;
};
}
export function DashboardStatCard({
title,
value,
description,
icon: Icon,
trend,
}: DashboardStatCardProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{description && (
<p className="text-xs text-muted-foreground mt-1">{description}</p>
)}
{trend && (
<p className="text-xs text-muted-foreground mt-1">
<span className={trend.value >= 0 ? "text-green-600" : "text-red-600"}>
{trend.value >= 0 ? "+" : ""}{trend.value}
</span>{" "}
{trend.label}
</p>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,28 @@
import { type NostrEvent } from '@nostrify/nostrify';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
/**
* Hook to fetch all events authored by a specific user
*/
export function useUserEvents(pubkey: string | undefined, limit = 50) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['user-events', pubkey, limit],
queryFn: async ({ signal }) => {
if (!pubkey) {
return [];
}
const events = await nostr.query(
[{ kinds: [1], authors: [pubkey], limit }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(3000)]) },
);
return events.sort((a, b) => b.created_at - a.created_at);
},
enabled: !!pubkey,
staleTime: 2 * 60 * 1000, // Keep cached data fresh for 2 minutes
});
}

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

@@ -0,0 +1,86 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { subDays, startOfDay, format } from 'date-fns';
interface UserStats {
totalPosts: number;
totalReactions: number;
totalReposts: number;
postsThisWeek: number;
postsThisMonth: number;
dailyActivity: Array<{ date: string; posts: number }>;
}
/**
* Hook to fetch and aggregate user statistics from Nostr events
*/
export function useUserStats(pubkey: string | undefined) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['user-stats', pubkey],
queryFn: async ({ signal }) => {
if (!pubkey) {
return null;
}
const now = Math.floor(Date.now() / 1000);
const weekAgo = now - 7 * 24 * 60 * 60;
const monthAgo = now - 30 * 24 * 60 * 60;
const thirtyDaysAgo = now - 30 * 24 * 60 * 60;
// Fetch user's posts
const posts = await nostr.query(
[{ kinds: [1], authors: [pubkey], limit: 500 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(3000)]) },
);
// Fetch reactions to user's posts (kind 7)
const reactions = await nostr.query(
[{ kinds: [7], '#p': [pubkey], limit: 500 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(3000)]) },
);
// Fetch reposts (kind 6 and 16)
const reposts = await nostr.query(
[{ kinds: [6, 16], '#p': [pubkey], limit: 500 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(3000)]) },
);
// Calculate stats
const totalPosts = posts.length;
const postsThisWeek = posts.filter((e) => e.created_at >= weekAgo).length;
const postsThisMonth = posts.filter((e) => e.created_at >= monthAgo).length;
// Calculate daily activity for the last 30 days
const dailyActivity: Array<{ date: string; posts: number }> = [];
for (let i = 29; i >= 0; i--) {
const date = startOfDay(subDays(new Date(), i));
const dateTimestamp = Math.floor(date.getTime() / 1000);
const nextDayTimestamp = dateTimestamp + 24 * 60 * 60;
const postsOnDay = posts.filter(
(e) => e.created_at >= dateTimestamp && e.created_at < nextDayTimestamp
).length;
dailyActivity.push({
date: format(date, 'MMM d'),
posts: postsOnDay,
});
}
const stats: UserStats = {
totalPosts,
totalReactions: reactions.length,
totalReposts: reposts.length,
postsThisWeek,
postsThisMonth,
dailyActivity,
};
return stats;
},
enabled: !!pubkey,
staleTime: 5 * 60 * 1000, // Keep cached data fresh for 5 minutes
});
}

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

@@ -0,0 +1,43 @@
import { useSeoMeta } from "@unhead/react";
import { DashboardLayout } from "@/components/DashboardLayout";
import { DashboardOverview } from "@/components/DashboardOverview";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { Card, CardContent } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Info } from "lucide-react";
export function Dashboard() {
const { user } = useCurrentUser();
useSeoMeta({
title: "Dashboard - LAYER.systems",
description: "View your Nostr activity and statistics",
});
return (
<DashboardLayout>
{!user ? (
<div className="max-w-4xl mx-auto">
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Please log in to view your dashboard. Use the login area in the sidebar to get started.
</AlertDescription>
</Alert>
</div>
) : (
<div className="max-w-7xl mx-auto space-y-6">
<div>
<h2 className="text-3xl font-bold tracking-tight">Welcome back!</h2>
<p className="text-muted-foreground">
Here's an overview of your Nostr activity
</p>
</div>
<DashboardOverview />
</div>
)}
</DashboardLayout>
);
}
export default Dashboard;