mirror of
https://github.com/layer-systems/website.git
synced 2026-06-17 01:58:30 +02:00
Add dashboard page with collapsible sidebar and user stats
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
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 */}
|
||||
|
||||
25
src/components/DashboardLayout.tsx
Normal file
25
src/components/DashboardLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
src/components/DashboardOverview.tsx
Normal file
160
src/components/DashboardOverview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
src/components/DashboardSidebar.tsx
Normal file
63
src/components/DashboardSidebar.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 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>
|
||||
);
|
||||
}
|
||||
44
src/components/DashboardStatCard.tsx
Normal file
44
src/components/DashboardStatCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/hooks/useUserEvents.ts
Normal file
28
src/hooks/useUserEvents.ts
Normal 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
86
src/hooks/useUserStats.ts
Normal 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
43
src/pages/Dashboard.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user