mirror of
https://github.com/layer-systems/website.git
synced 2026-06-17 01:58:30 +02:00
feat: add DashboardEvents page and integrate EventExplorer component
This commit is contained in:
@@ -4,6 +4,7 @@ import { ScrollToTop } from "./components/ScrollToTop";
|
||||
import Index from "./pages/Index";
|
||||
import { Explore } from "./pages/Explore";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { DashboardEvents } from "./pages/DashboardEvents";
|
||||
import { NIP19Page } from "./pages/NIP19Page";
|
||||
import { Terms } from "./pages/Terms";
|
||||
import { Privacy } from "./pages/Privacy";
|
||||
@@ -17,6 +18,7 @@ export function AppRouter() {
|
||||
<Route path="/" element={<Index />} />
|
||||
<Route path="/explore" element={<Explore />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/dashboard/events" element={<DashboardEvents />} />
|
||||
<Route path="/terms" element={<Terms />} />
|
||||
<Route path="/privacy" element={<Privacy />} />
|
||||
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
|
||||
|
||||
370
src/components/dashboard/EventExplorer.tsx
Normal file
370
src/components/dashboard/EventExplorer.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useUserStats } from '@/hooks/useUserStats';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Trash2, Search, Info } from 'lucide-react';
|
||||
|
||||
const kindLabels: Record<number, string> = {
|
||||
0: 'Metadata',
|
||||
1: 'Text Note',
|
||||
3: 'Contacts',
|
||||
4: 'DM',
|
||||
5: 'Deletion',
|
||||
6: 'Repost',
|
||||
7: 'Reaction',
|
||||
16: 'Generic Repost',
|
||||
40: 'Channel Create',
|
||||
41: 'Channel Metadata',
|
||||
42: 'Channel Message',
|
||||
43: 'Channel Hide',
|
||||
44: 'Channel Mute',
|
||||
1984: 'Report',
|
||||
9735: 'Zap',
|
||||
10002: 'Relay List',
|
||||
30023: 'Article',
|
||||
};
|
||||
|
||||
interface EventExplorerProps {
|
||||
pubkey: string;
|
||||
}
|
||||
|
||||
const EVENTS_PER_PAGE = 20;
|
||||
|
||||
export function EventExplorer({ pubkey }: EventExplorerProps) {
|
||||
const { data: stats, isLoading, isError } = useUserStats(pubkey);
|
||||
const { mutateAsync: publishDeletion, isPending } = useNostrPublish();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedEvent, setSelectedEvent] = useState<NostrEvent | null>(null);
|
||||
const [reason, setReason] = useState('I would like this event to be deleted.');
|
||||
|
||||
const filteredEvents = useMemo(() => {
|
||||
if (!stats) return [];
|
||||
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
|
||||
if (!normalizedSearch) {
|
||||
return stats.events;
|
||||
}
|
||||
|
||||
return stats.events.filter((event) => {
|
||||
if (!event.content) return false;
|
||||
return event.content.toLowerCase().includes(normalizedSearch);
|
||||
});
|
||||
}, [stats, search]);
|
||||
|
||||
const totalPages = useMemo(() => {
|
||||
if (filteredEvents.length === 0) return 1;
|
||||
return Math.ceil(filteredEvents.length / EVENTS_PER_PAGE);
|
||||
}, [filteredEvents.length]);
|
||||
|
||||
const pageSafe = Math.min(Math.max(page, 1), totalPages);
|
||||
|
||||
const paginatedEvents = useMemo(() => {
|
||||
const start = (pageSafe - 1) * EVENTS_PER_PAGE;
|
||||
const end = start + EVENTS_PER_PAGE;
|
||||
return filteredEvents.slice(start, end);
|
||||
}, [filteredEvents, pageSafe]);
|
||||
|
||||
const handleOpenDialog = (event: NostrEvent) => {
|
||||
setSelectedEvent(event);
|
||||
setReason('I would like this event to be deleted.');
|
||||
};
|
||||
|
||||
const handleRequestDeletion = async () => {
|
||||
if (!selectedEvent) return;
|
||||
|
||||
try {
|
||||
await publishDeletion({
|
||||
kind: 5,
|
||||
content: reason.trim() || 'Requesting deletion of this event.',
|
||||
tags: [
|
||||
['e', selectedEvent.id],
|
||||
['p', selectedEvent.pubkey],
|
||||
],
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Deletion request sent',
|
||||
description: 'A Nostr deletion event has been published.',
|
||||
});
|
||||
|
||||
setSelectedEvent(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to publish deletion event:', error);
|
||||
toast({
|
||||
title: 'Deletion request failed',
|
||||
description: 'Unable to publish deletion request. Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-40 mb-2" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, index) => (
|
||||
<div key={index} className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-28" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !stats) {
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
<CardHeader>
|
||||
<CardTitle>Your Events</CardTitle>
|
||||
<CardDescription>
|
||||
Explore your published events and manage deletion requests.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Unable to load your events. Please check your relay connections and try again.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (stats.events.length === 0) {
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
<CardHeader>
|
||||
<CardTitle>Your Events</CardTitle>
|
||||
<CardDescription>
|
||||
Explore your published events and manage deletion requests.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center justify-center space-y-3 py-8 text-center">
|
||||
<Info className="h-6 w-6 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
You have not published any events yet. Once you start posting on Nostr, your activity will appear here.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>Your Events</CardTitle>
|
||||
<CardDescription>
|
||||
Browse your published events and request deletion directly from your dashboard.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="w-full sm:w-64">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-8"
|
||||
placeholder="Search content..."
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="pr-4">
|
||||
<div className="space-y-3">
|
||||
{paginatedEvents.map((event) => {
|
||||
const label = kindLabels[event.kind] || `Kind ${event.kind}`;
|
||||
const createdAt = new Date(event.created_at * 1000);
|
||||
const contentPreview = event.content
|
||||
? event.content.slice(0, 120) + (event.content.length > 120 ? '…' : '')
|
||||
: 'No content';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className="flex flex-col gap-2 rounded-lg border bg-card/40 p-3 transition hover:bg-accent/40 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{label}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{createdAt.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 max-w-xl">
|
||||
{contentPreview}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<AlertDialog
|
||||
open={selectedEvent?.id === event.id}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSelectedEvent((current) =>
|
||||
current?.id === event.id ? null : current,
|
||||
);
|
||||
} else {
|
||||
handleOpenDialog(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => handleOpenDialog(event)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Request deletion
|
||||
</Button>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Request deletion of this event?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will publish a Nostr deletion event (kind 5) referencing the
|
||||
selected event. Relays and clients may remove or hide the original
|
||||
event in response.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="space-y-3 py-2 text-sm">
|
||||
<div className="rounded-md bg-muted px-3 py-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Badge variant="secondary" className="text-2xs">
|
||||
{label}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{createdAt.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-3">
|
||||
{contentPreview}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
Deletion reason (optional)
|
||||
</p>
|
||||
<Textarea
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
className="min-h-[80px] text-sm"
|
||||
placeholder="Explain why you are requesting this deletion."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleRequestDeletion}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? 'Publishing…' : 'Publish deletion request'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div>
|
||||
Showing
|
||||
{' '}
|
||||
<span className="font-medium">
|
||||
{paginatedEvents.length === 0
|
||||
? 0
|
||||
: (pageSafe - 1) * EVENTS_PER_PAGE + 1}
|
||||
</span>
|
||||
{' '}
|
||||
to
|
||||
{' '}
|
||||
<span className="font-medium">
|
||||
{(pageSafe - 1) * EVENTS_PER_PAGE + paginatedEvents.length}
|
||||
</span>
|
||||
{' '}
|
||||
of
|
||||
{' '}
|
||||
<span className="font-medium">{filteredEvents.length}</span>
|
||||
{' '}
|
||||
events
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pageSafe <= 1}
|
||||
onClick={() => setPage((current) => Math.max(current - 1, 1))}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span>
|
||||
Page
|
||||
{' '}
|
||||
<span className="font-medium">{pageSafe}</span>
|
||||
{' '}
|
||||
of
|
||||
{' '}
|
||||
<span className="font-medium">{totalPages}</span>
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={pageSafe >= totalPages}
|
||||
onClick={() =>
|
||||
setPage((current) => Math.min(current + 1, totalPages))
|
||||
}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Home, LayoutDashboard } from 'lucide-react';
|
||||
import { Home, LayoutDashboard, FileText } from 'lucide-react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -24,6 +24,11 @@ const navigationItems = [
|
||||
url: '/dashboard',
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
title: 'My Events',
|
||||
url: '/dashboard/events',
|
||||
icon: FileText,
|
||||
},
|
||||
];
|
||||
|
||||
export function AppSidebar() {
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface UserStats {
|
||||
eventsByKind: Record<number, number>;
|
||||
recentEvents: NostrEvent[];
|
||||
lastActivity?: number;
|
||||
events: NostrEvent[];
|
||||
}
|
||||
|
||||
export function useUserStats(pubkey: string | undefined) {
|
||||
@@ -54,6 +55,7 @@ export function useUserStats(pubkey: string | undefined) {
|
||||
eventsByKind,
|
||||
recentEvents,
|
||||
lastActivity: lastActivity > 0 ? lastActivity : undefined,
|
||||
events,
|
||||
};
|
||||
|
||||
return stats;
|
||||
|
||||
58
src/pages/DashboardEvents.tsx
Normal file
58
src/pages/DashboardEvents.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { AppSidebar } from '@/components/navigation/AppSidebar';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { EventExplorer } from '@/components/dashboard/EventExplorer';
|
||||
|
||||
export function DashboardEvents() {
|
||||
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">My Events</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 explore your events and manage deletion requests.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
Your Nostr events
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl">
|
||||
Browse all events you have published on Nostr, search through their content,
|
||||
and publish deletion requests when you want something removed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<EventExplorer pubkey={user.pubkey} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardEvents;
|
||||
Reference in New Issue
Block a user