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 '@/lib/dateFnsI18n'; import { useTranslation } from '@/contexts/language'; // 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 { t } = useTranslation(); 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 t('settingsUi.myData.reactionTitle', { emoji }); } // 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 { t } = useTranslation(); 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: t('settingsUi.myData.all'), icon: Database, color: 'text-white' }, ...(eventsByKind[KINDS.SPOT]?.length ? [{ kind: KINDS.SPOT, label: t('settingsUi.myData.spots'), icon: MapPin, color: 'text-[#ccff00]' }] : []), ...(eventsByKind[KINDS.EVENT]?.length ? [{ kind: KINDS.EVENT, label: t('settingsUi.myData.events'), icon: Calendar, color: 'text-[#ff0040]' }] : []), ...(eventsByKind[KINDS.SHOP]?.length ? [{ kind: KINDS.SHOP, label: t('settingsUi.myData.shops'), icon: Store, color: 'text-purple-400' }] : []), ...(eventsByKind[KINDS.LISTING]?.length ? [{ kind: KINDS.LISTING, label: t('settingsUi.myData.listings'), icon: ShoppingBag, color: 'text-orange-400' }] : []), ...(eventsByKind[KINDS.COLLECTION]?.length ? [{ kind: KINDS.COLLECTION, label: t('settingsUi.myData.collections'), icon: FolderHeart, color: 'text-pink-400' }] : []), ...((eventsByKind[KINDS.COACH]?.length || eventsByKind[KINDS.FILMER]?.length) ? [{ kind: KINDS.COACH, label: t('settingsUi.myData.lab'), icon: GraduationCap, color: 'text-green-400' }] : []), ...((eventsByKind[KINDS.POST]?.length || eventsByKind[KINDS.COMMENT]?.length) ? [{ kind: KINDS.POST, label: t('settingsUi.myData.posts'), icon: MessageSquare, color: 'text-blue-400' }] : []), ...(eventsByKind[KINDS.REACTION]?.length ? [{ kind: KINDS.REACTION, label: t('settingsUi.myData.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 ( {t('settingsUi.myData.selectContentTitle')} {t('settingsUi.myData.selectContentDesc')}
{/* 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 */}
|
{t('settingsUi.myData.selectedCount', { count: selectedCount })}
{/* Event list */}
{displayEvents.length === 0 ? (
{t('settingsUi.myData.noItemsCategory')}
) : ( 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 */}

{t('settingsUi.myData.decentralizedStrong')} {t('settingsUi.myData.decentralizedNote')}

); } export function MyDataTab() { const { user } = useCurrentUser(); const { t } = useTranslation(); 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: t('settingsUi.myData.noDataTitle'), description: t('settingsUi.myData.noDataDesc'), 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: t('settingsUi.myData.exportedTitle'), description: t('settingsUi.myData.exportedDesc', { count: userEvents.length }), }); }; // 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: t('settingsUi.myData.deletionRequestedTitle'), description: t('settingsUi.myData.deletionRequestedDesc'), }); refetch(); } catch (error) { console.error('Failed to delete event:', error); toast({ title: t('settingsUi.myData.deletionFailedTitle'), description: t('settingsUi.myData.deletionFailedDesc'), 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: t('settingsUi.myData.deletionRequestedTitle'), description: t('settingsUi.myData.deletionRequestedMany', { count: eventsToDelete.length }), }); queryClient.invalidateQueries({ queryKey: ['my-data'] }); refetch(); } catch (error) { console.error('Failed to delete events:', error); toast({ title: t('settingsUi.myData.deletionFailedTitle'), description: t('settingsUi.myData.deletionFailedDesc'), variant: 'destructive', }); } finally { setIsDeleting(false); } }; if (!user) { return (

{t('settingsUi.myData.loginRequired')}

{t('settingsUi.myData.loginRequiredDesc')}

); } const displayedEvents = showAllActivity ? userEvents : userEvents?.slice(0, 10); return (
{/* Header Card */} {t('settingsUi.myData.title')} {t('settingsUi.myData.desc')} {/* Stats Grid - responsive 2x4 on mobile, 4x2 on desktop */}
{/* Row 1: Main content */}
{/* Row 2: Social & Lab */}
{/* Action Buttons */}
{/* Recent Activity */}
{t('settingsUi.myData.recentActivity')} {userEvents && userEvents.length > 10 && ( )}
{isLoading ? (
{[...Array(5)].map((_, i) => ( ))}
) : !userEvents?.length ? (

{t('settingsUi.myData.noDataPublished')}

{t('settingsUi.myData.startBy')} {t('settingsUi.myData.addingASpot')}!

) : (
{displayedEvents?.map((event) => ( ))}
)}
{/* Data Ownership Info */} {t('settingsUi.myData.aboutYourData')}

{t('settingsUi.myData.ownDataStrong')} {t('settingsUi.myData.ownData')}

  • ✓ {t('settingsUi.myData.exportAnytimeStrong')} {t('settingsUi.myData.exportAnytime')}
  • ✓ {t('settingsUi.myData.portableStrong')} {t('settingsUi.myData.portable')}
  • ✓ {t('settingsUi.myData.selectiveStrong')} {t('settingsUi.myData.selective')}
  • ⚠ {t('settingsUi.myData.caveatStrong')} {t('settingsUi.myData.caveat')}
); }