From 29bd0bb95e4ab84d94831fa7abb4add8d4d00374 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 12:54:17 +0000 Subject: [PATCH] Add dashboard page with collapsible sidebar and user stats Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com> --- src/AppRouter.tsx | 2 + src/components/DashboardLayout.tsx | 25 +++++ src/components/DashboardOverview.tsx | 160 +++++++++++++++++++++++++++ src/components/DashboardSidebar.tsx | 63 +++++++++++ src/components/DashboardStatCard.tsx | 44 ++++++++ src/hooks/useUserEvents.ts | 28 +++++ src/hooks/useUserStats.ts | 86 ++++++++++++++ src/pages/Dashboard.tsx | 43 +++++++ 8 files changed, 451 insertions(+) create mode 100644 src/components/DashboardLayout.tsx create mode 100644 src/components/DashboardOverview.tsx create mode 100644 src/components/DashboardSidebar.tsx create mode 100644 src/components/DashboardStatCard.tsx create mode 100644 src/hooks/useUserEvents.ts create mode 100644 src/hooks/useUserStats.ts create mode 100644 src/pages/Dashboard.tsx 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/DashboardLayout.tsx b/src/components/DashboardLayout.tsx new file mode 100644 index 0000000..6b3897f --- /dev/null +++ b/src/components/DashboardLayout.tsx @@ -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 ( + +
+ +
+
+ +

Dashboard

+
+
+ {children} +
+
+
+
+ ); +} diff --git a/src/components/DashboardOverview.tsx b/src/components/DashboardOverview.tsx new file mode 100644 index 0000000..ab3c610 --- /dev/null +++ b/src/components/DashboardOverview.tsx @@ -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 ( +
+
+ {[...Array(4)].map((_, i) => ( + + + + + + + + + + + ))} +
+ + + + + + + + + +
+ ); + } + + if (error) { + return ( + + +

+ Failed to load statistics. Please try again later. +

+
+
+ ); + } + + if (!stats) { + return ( + + +

+ No data available. Start posting to see your statistics! +

+
+
+ ); + } + + const chartConfig = { + posts: { + label: "Posts", + color: "hsl(var(--primary))", + }, + }; + + return ( +
+ {/* Stats Grid */} +
+ + + + +
+ + {/* Activity Chart */} + + + Activity Overview + Your posting activity over the last 30 days + + + + + + + + + + + + value} + /> + + } + /> + + + + + +
+ ); +} diff --git a/src/components/DashboardSidebar.tsx b/src/components/DashboardSidebar.tsx new file mode 100644 index 0000000..69d888f --- /dev/null +++ b/src/components/DashboardSidebar.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 menuItems = [ + { + title: "Home", + url: "/", + icon: Home, + }, + { + title: "Dashboard", + url: "/dashboard", + icon: LayoutDashboard, + }, +]; + +export function DashboardSidebar() { + const location = useLocation(); + + return ( + + + + Navigation + + + {menuItems.map((item) => ( + + + + + {item.title} + + + + ))} + + + + + +
+ +
+
+
+ ); +} diff --git a/src/components/DashboardStatCard.tsx b/src/components/DashboardStatCard.tsx new file mode 100644 index 0000000..61a48f6 --- /dev/null +++ b/src/components/DashboardStatCard.tsx @@ -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 ( + + + {title} + + + +
{value}
+ {description && ( +

{description}

+ )} + {trend && ( +

+ = 0 ? "text-green-600" : "text-red-600"}> + {trend.value >= 0 ? "+" : ""}{trend.value} + {" "} + {trend.label} +

+ )} +
+
+ ); +} diff --git a/src/hooks/useUserEvents.ts b/src/hooks/useUserEvents.ts new file mode 100644 index 0000000..b3a7739 --- /dev/null +++ b/src/hooks/useUserEvents.ts @@ -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 + }); +} diff --git a/src/hooks/useUserStats.ts b/src/hooks/useUserStats.ts new file mode 100644 index 0000000..de96f3b --- /dev/null +++ b/src/hooks/useUserStats.ts @@ -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 + }); +} diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx new file mode 100644 index 0000000..5129fca --- /dev/null +++ b/src/pages/Dashboard.tsx @@ -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 ( + + {!user ? ( +
+ + + + Please log in to view your dashboard. Use the login area in the sidebar to get started. + + +
+ ) : ( +
+
+

Welcome back!

+

+ Here's an overview of your Nostr activity +

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