import { useState } from 'react'; import { Link } from 'react-router-dom'; import { useNostr } from '@nostrify/react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { NostrEvent } from '@nostrify/nostrify'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { LoginArea } from '@/components/auth/LoginArea'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { Checkbox } from '@/components/ui/checkbox'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; import { toast } from '@/hooks/useToast'; import { Database, Download, Trash2, MapPin, Calendar, Store, MessageSquare, FileJson, Clock, ExternalLink, AlertTriangle, ShoppingBag, GraduationCap, FolderHeart, Video, Heart, Zap, } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; // Kind constants const KINDS = { POST: 1, REACTION: 7, ZAP_RECEIPT: 9735, COMMENT: 1111, SPOT: 30818, EVENT: 31923, SHOP: 30017, LISTING: 30402, COLLECTION: 30003, COACH: 35001, FILMER: 35002, }; // Event kind labels and icons const KIND_CONFIG: Record = { [KINDS.POST]: { label: 'Post', icon: MessageSquare, color: 'text-blue-400', category: 'posts' }, [KINDS.REACTION]: { label: 'Reaction', icon: Heart, color: 'text-pink-400', category: 'reactions' }, [KINDS.COMMENT]: { label: 'Comment', icon: MessageSquare, color: 'text-gray-400', category: 'comments' }, [KINDS.SPOT]: { label: 'Spot', icon: MapPin, color: 'text-[#ccff00]', category: 'spots' }, [KINDS.EVENT]: { label: 'Event', icon: Calendar, color: 'text-[#ff0040]', category: 'events' }, [KINDS.SHOP]: { label: 'Shop', icon: Store, color: 'text-purple-400', category: 'shops' }, [KINDS.LISTING]: { label: 'Listing', icon: ShoppingBag, color: 'text-orange-400', category: 'listings' }, [KINDS.COLLECTION]: { label: 'Collection', icon: FolderHeart, color: 'text-pink-400', category: 'collections' }, [KINDS.COACH]: { label: 'Coach Profile', icon: GraduationCap, color: 'text-green-400', category: 'labProfiles' }, [KINDS.FILMER]: { label: 'Filmer Profile', icon: Video, color: 'text-cyan-400', category: 'labProfiles' }, }; interface DataStats { spots: number; events: number; shops: number; listings: number; posts: number; comments: number; collections: number; labProfiles: number; reactions: number; zapsReceived: number; total: number; } function StatCard({ icon: Icon, label, count, color, isLoading, subtitle, }: { icon: typeof MapPin; label: string; count: number; color: string; isLoading: boolean; subtitle?: string; }) { return (
{isLoading ? ( ) : ( {count} )} {label} {subtitle && {subtitle}}
); } function EventRow({ event, onDelete }: { event: NostrEvent; onDelete: (event: NostrEvent) => void; }) { const config = KIND_CONFIG[event.kind] || { label: `Kind ${event.kind}`, icon: FileJson, color: 'text-gray-400', category: 'other' }; const Icon = config.icon; // Get title/identifier for the event const getEventTitle = () => { // For reactions, show what was reacted to if (event.kind === KINDS.REACTION) { const emoji = event.content || '❤️'; return `${emoji} reaction`; } // Check for title tag const titleTag = event.tags.find(([k]) => k === 'title')?.[1]; if (titleTag) return titleTag; // Check for d tag (identifier) const dTag = event.tags.find(([k]) => k === 'd')?.[1]; if (dTag) return dTag; // For posts/comments, use truncated content if (event.content) { return event.content.length > 50 ? event.content.slice(0, 50) + '...' : event.content; } return `Event ${event.id.slice(0, 8)}...`; }; return (

{getEventTitle()}

{config.label} • {formatDistanceToNow(event.created_at * 1000, { addSuffix: true })}
); } // Helper to get event title function getEventTitle(event: NostrEvent): string { // For reactions, show what was reacted to if (event.kind === KINDS.REACTION) { const emoji = event.content || '❤️'; return `${emoji} reaction`; } // Check for title tag const titleTag = event.tags.find(([k]) => k === 'title')?.[1]; if (titleTag) return titleTag; // Check for name tag (for shops) const nameTag = event.tags.find(([k]) => k === 'name')?.[1]; if (nameTag) return nameTag; // Check for d tag (identifier) const dTag = event.tags.find(([k]) => k === 'd')?.[1]; if (dTag && dTag.length < 40) return dTag; // For posts/comments, use truncated content if (event.content) { const cleaned = event.content.replace(/\n/g, ' ').trim(); return cleaned.length > 40 ? cleaned.slice(0, 40) + '...' : cleaned; } return `Event ${event.id.slice(0, 8)}...`; } function SelectiveDeletionDialog({ userEvents, onDeleteSelected, isDeleting, }: { userEvents: NostrEvent[]; onDeleteSelected: (events: NostrEvent[]) => Promise; isDeleting: boolean; }) { const [open, setOpen] = useState(false); const [selectedEvents, setSelectedEvents] = useState>(new Set()); const [filterKind, setFilterKind] = useState('all'); // Group events by kind for the filter tabs const eventsByKind = userEvents.reduce((acc, event) => { if (!acc[event.kind]) acc[event.kind] = []; acc[event.kind].push(event); return acc; }, {} as Record); // Get filtered events const filteredEvents = filterKind === 'all' ? userEvents : userEvents.filter(e => e.kind === filterKind); const toggleEvent = (eventId: string) => { const newSelected = new Set(selectedEvents); if (newSelected.has(eventId)) { newSelected.delete(eventId); } else { newSelected.add(eventId); } setSelectedEvents(newSelected); }; const selectAllVisible = () => { const newSelected = new Set(selectedEvents); for (const event of filteredEvents) { newSelected.add(event.id); } setSelectedEvents(newSelected); }; const selectNone = () => { if (filterKind === 'all') { setSelectedEvents(new Set()); } else { // Only deselect visible items const newSelected = new Set(selectedEvents); for (const event of filteredEvents) { newSelected.delete(event.id); } setSelectedEvents(newSelected); } }; const handleDelete = async () => { const eventsToDelete = userEvents.filter(e => selectedEvents.has(e.id)); await onDeleteSelected(eventsToDelete); setOpen(false); setSelectedEvents(new Set()); }; const selectedCount = selectedEvents.size; // Available filter tabs const filterTabs: { kind: number | 'all'; label: string; icon: typeof MapPin; color: string }[] = [ { kind: 'all', label: 'All', icon: Database, color: 'text-white' }, ...(eventsByKind[KINDS.SPOT]?.length ? [{ kind: KINDS.SPOT, label: 'Spots', icon: MapPin, color: 'text-[#ccff00]' }] : []), ...(eventsByKind[KINDS.EVENT]?.length ? [{ kind: KINDS.EVENT, label: 'Events', icon: Calendar, color: 'text-[#ff0040]' }] : []), ...(eventsByKind[KINDS.SHOP]?.length ? [{ kind: KINDS.SHOP, label: 'Shops', icon: Store, color: 'text-purple-400' }] : []), ...(eventsByKind[KINDS.LISTING]?.length ? [{ kind: KINDS.LISTING, label: 'Listings', icon: ShoppingBag, color: 'text-orange-400' }] : []), ...(eventsByKind[KINDS.COLLECTION]?.length ? [{ kind: KINDS.COLLECTION, label: 'Collections', icon: FolderHeart, color: 'text-pink-400' }] : []), ...((eventsByKind[KINDS.COACH]?.length || eventsByKind[KINDS.FILMER]?.length) ? [{ kind: KINDS.COACH, label: 'Lab', icon: GraduationCap, color: 'text-green-400' }] : []), ...((eventsByKind[KINDS.POST]?.length || eventsByKind[KINDS.COMMENT]?.length) ? [{ kind: KINDS.POST, label: 'Posts', icon: MessageSquare, color: 'text-blue-400' }] : []), ...(eventsByKind[KINDS.REACTION]?.length ? [{ kind: KINDS.REACTION, label: 'Reactions', icon: Heart, color: 'text-pink-400' }] : []), ]; // Handle special filter cases (Lab = Coach + Filmer, Posts = Post + Comment) const getFilteredEventsForTab = () => { if (filterKind === 'all') return userEvents; if (filterKind === KINDS.COACH) return userEvents.filter(e => e.kind === KINDS.COACH || e.kind === KINDS.FILMER); if (filterKind === KINDS.POST) return userEvents.filter(e => e.kind === KINDS.POST || e.kind === KINDS.COMMENT); return userEvents.filter(e => e.kind === filterKind); }; const displayEvents = getFilteredEventsForTab(); return ( Select Content to Delete Select the specific items you want to request deletion for.
{/* Filter tabs */}
{filterTabs.map((tab) => { const Icon = tab.icon; const isActive = filterKind === tab.kind; const count = tab.kind === 'all' ? userEvents.length : tab.kind === KINDS.COACH ? (eventsByKind[KINDS.COACH]?.length || 0) + (eventsByKind[KINDS.FILMER]?.length || 0) : tab.kind === KINDS.POST ? (eventsByKind[KINDS.POST]?.length || 0) + (eventsByKind[KINDS.COMMENT]?.length || 0) : eventsByKind[tab.kind]?.length || 0; return ( ); })}
{/* Quick actions */}
|
{selectedCount} selected
{/* Event list */}
{displayEvents.length === 0 ? (
No items in this category
) : ( displayEvents.map((event) => { const config = KIND_CONFIG[event.kind] || { label: `Kind ${event.kind}`, icon: FileJson, color: 'text-gray-400', category: 'other' }; const Icon = config.icon; const isChecked = selectedEvents.has(event.id); return ( ); }) )}
{/* Warning */}

Important: Nostr is decentralized. Relays should honor deletion requests, but some may not. This is not a guaranteed permanent deletion.

); } export function MyDataTab() { const { user } = useCurrentUser(); const { nostr } = useNostr(); const queryClient = useQueryClient(); const { mutateAsync: publishEvent } = useNostrPublish(); const [isDeleting, setIsDeleting] = useState(false); const [showAllActivity, setShowAllActivity] = useState(false); // Fetch all user events const { data: userEvents, isLoading, refetch } = useQuery({ queryKey: ['my-data', user?.pubkey], queryFn: async ({ signal }) => { if (!user?.pubkey) return []; const querySignal = AbortSignal.any([signal, AbortSignal.timeout(15000)]); // Query for all event kinds the user might have published const events = await nostr.query([ { authors: [user.pubkey], kinds: [ KINDS.POST, KINDS.REACTION, KINDS.COMMENT, KINDS.SPOT, KINDS.EVENT, KINDS.SHOP, KINDS.LISTING, KINDS.COLLECTION, KINDS.COACH, KINDS.FILMER ], limit: 500 } ], { signal: querySignal }); // Sort by created_at descending return events.sort((a, b) => b.created_at - a.created_at); }, enabled: !!user?.pubkey, staleTime: 60000, }); // Fetch zaps received (where user is the recipient) const { data: zapsReceived } = useQuery({ queryKey: ['my-zaps-received', user?.pubkey], queryFn: async ({ signal }) => { if (!user?.pubkey) return []; const querySignal = AbortSignal.any([signal, AbortSignal.timeout(10000)]); // Query for zap receipts where user is tagged const events = await nostr.query([ { kinds: [KINDS.ZAP_RECEIPT], '#p': [user.pubkey], limit: 100 } ], { signal: querySignal }); return events; }, enabled: !!user?.pubkey, staleTime: 60000, }); // Calculate stats const stats: DataStats = { spots: userEvents?.filter(e => e.kind === KINDS.SPOT).length || 0, events: userEvents?.filter(e => e.kind === KINDS.EVENT).length || 0, shops: userEvents?.filter(e => e.kind === KINDS.SHOP).length || 0, listings: userEvents?.filter(e => e.kind === KINDS.LISTING).length || 0, posts: userEvents?.filter(e => e.kind === KINDS.POST).length || 0, comments: userEvents?.filter(e => e.kind === KINDS.COMMENT).length || 0, collections: userEvents?.filter(e => e.kind === KINDS.COLLECTION).length || 0, labProfiles: userEvents?.filter(e => e.kind === KINDS.COACH || e.kind === KINDS.FILMER).length || 0, reactions: userEvents?.filter(e => e.kind === KINDS.REACTION).length || 0, zapsReceived: zapsReceived?.length || 0, total: userEvents?.length || 0, }; // Export all data as JSON const handleExportData = () => { if (!userEvents || userEvents.length === 0) { toast({ title: 'No data to export', description: 'You haven\'t published any events yet.', variant: 'destructive', }); return; } const exportData = { exportedAt: new Date().toISOString(), pubkey: user?.pubkey, stats, eventCount: userEvents.length, zapsReceivedCount: zapsReceived?.length || 0, events: userEvents, zapsReceived: zapsReceived || [], }; const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `skatespots-data-${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); toast({ title: 'Data exported!', description: `Downloaded ${userEvents.length} events as JSON.`, }); }; // Delete a single event const handleDeleteEvent = async (event: NostrEvent) => { try { const isAddressable = event.kind >= 30000 && event.kind < 40000; const tags: string[][] = [['k', String(event.kind)]]; if (isAddressable) { const dTag = event.tags.find(([k]) => k === 'd')?.[1] || ''; tags.push(['a', `${event.kind}:${event.pubkey}:${dTag}`]); } else { tags.push(['e', event.id]); } await publishEvent({ kind: 5, content: 'Deletion requested via SkateSpots', tags, }); toast({ title: 'Deletion requested', description: 'The deletion request has been published to relays.', }); refetch(); } catch (error) { console.error('Failed to delete event:', error); toast({ title: 'Deletion failed', description: 'Could not publish deletion request. Please try again.', variant: 'destructive', }); } }; // Delete selected events const handleDeleteSelected = async (eventsToDelete: NostrEvent[]) => { if (!eventsToDelete || eventsToDelete.length === 0) return; setIsDeleting(true); try { const deletionTags: string[][] = []; const kindsToDelete = new Set(); for (const event of eventsToDelete) { kindsToDelete.add(event.kind); const isAddressable = event.kind >= 30000 && event.kind < 40000; if (isAddressable) { const dTag = event.tags.find(([k]) => k === 'd')?.[1] || ''; deletionTags.push(['a', `${event.kind}:${event.pubkey}:${dTag}`]); } else { deletionTags.push(['e', event.id]); } } for (const kind of kindsToDelete) { deletionTags.push(['k', String(kind)]); } await publishEvent({ kind: 5, content: 'Selective deletion requested via SkateSpots - user exercising right to delete their data', tags: deletionTags, }); toast({ title: 'Deletion requested', description: `Requested deletion of ${eventsToDelete.length} items. Note: Some relays may not honor this request.`, }); queryClient.invalidateQueries({ queryKey: ['my-data'] }); refetch(); } catch (error) { console.error('Failed to delete events:', error); toast({ title: 'Deletion failed', description: 'Could not publish deletion request. Please try again.', variant: 'destructive', }); } finally { setIsDeleting(false); } }; if (!user) { return (

Login Required

Log in to view and manage your data.

); } const displayedEvents = showAllActivity ? userEvents : userEvents?.slice(0, 10); return (
{/* Header Card */} My Data Your content on the Nostr network. You own this data - export it anytime or request deletion. {/* Stats Grid - responsive 2x4 on mobile, 4x2 on desktop */}
{/* Row 1: Main content */}
{/* Row 2: Social & Lab */}
{/* Action Buttons */}
{/* Recent Activity */}
Recent Activity {userEvents && userEvents.length > 10 && ( )}
{isLoading ? (
{[...Array(5)].map((_, i) => ( ))}
) : !userEvents?.length ? (

No data published yet

Start by adding a spot!

) : (
{displayedEvents?.map((event) => ( ))}
)}
{/* Data Ownership Info */} About Your Data

You own your data. All content is signed with your cryptographic keys and stored on Nostr relays. No company controls it.

  • ✓ Export anytime - Download all your events as JSON
  • ✓ Portable - Use your keys in any Nostr app
  • ✓ Selective deletion - Choose exactly what to delete
  • ⚠ Decentralized caveat - Some relays may retain data despite deletion requests
); }