SkateSpots/src/components/MyDataTab.tsx
shakespeare.diy e29d35f1e4 fix: restore full skate-spots source tree from misplaced src/src/ copy
The entire skate-spots codebase was accidentally copied into src/src/ and
public/public/ instead of src/ and public/ directly. This left the real
src/ directory as blank MKStack template stubs, explaining why the landing
page showed the default template instead of SkateSpots.

Changes made:
- Copied all files from src/src/ up to src/ (App.tsx, AppRouter.tsx, main.tsx,
  all pages, components, hooks, contexts, lib, i18n, test)
- Copied all files from public/public/ up to public/ (images, manifest, etc.)
- Deleted src/src/ and public/public/ nested directories
- Removed HelloWorld.tsx template stub from pages/
- Retained the previously fixed index.css (Tailwind v3 directives)

Full skate-spots feature set is now active:
- All 30+ routes (map, events, spots, coaches, marketplace, wallet, etc.)
- NWCProvider, LanguageProvider, PWAInstallPrompt, AmberCallbackHandler
- Skate community relays (goskatespots.nostr1.com, wotr.relatr.xyz, etc.)
- Outfit Variable font, Amber pre-check in main.tsx
- All hooks (useSkateSpots, useCoaches, useZaps, etc.)
- i18n support (en/es)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-15 22:18:28 +07:00

792 lines
29 KiB
TypeScript

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<number, { label: string; icon: typeof MapPin; color: string; category: string }> = {
[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 (
<div className="flex flex-col items-center justify-center p-2 sm:p-4 rounded-lg sm:rounded-xl bg-black/50 border border-gray-800 hover:border-gray-700 transition-colors min-w-0">
<Icon className={`h-4 w-4 sm:h-5 sm:w-5 mb-1 sm:mb-1.5 ${color}`} />
{isLoading ? (
<Skeleton className="h-5 sm:h-7 w-8 sm:w-10 mb-0.5" />
) : (
<span className="text-base sm:text-xl font-black text-white">{count}</span>
)}
<span className="text-[9px] sm:text-[10px] text-gray-400 uppercase tracking-wide text-center leading-tight">{label}</span>
{subtitle && <span className="text-[8px] sm:text-[9px] text-gray-500 leading-tight">{subtitle}</span>}
</div>
);
}
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 (
<div className="flex items-center gap-3 p-3 rounded-lg bg-black/30 border border-gray-800 hover:border-gray-700 transition-colors group">
<div className={`p-2 rounded-lg bg-gray-900 ${config.color}`}>
<Icon className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{getEventTitle()}</p>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className={config.color}>{config.label}</span>
<span>•</span>
<Clock className="h-3 w-3" />
<span>{formatDistanceToNow(event.created_at * 1000, { addSuffix: true })}</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(event)}
className="opacity-0 group-hover:opacity-100 transition-opacity text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
}
// 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<void>;
isDeleting: boolean;
}) {
const [open, setOpen] = useState(false);
const [selectedEvents, setSelectedEvents] = useState<Set<string>>(new Set());
const [filterKind, setFilterKind] = useState<number | 'all'>('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<number, NostrEvent[]>);
// 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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
disabled={!userEvents?.length || isDeleting}
className="flex-1 border-red-500/50 text-red-400 hover:bg-red-500/10"
>
<Trash2 className="h-4 w-4 mr-2" />
{isDeleting ? 'Deleting...' : 'Request Deletion'}
</Button>
</DialogTrigger>
<DialogContent className="bg-gray-900 border-gray-700 max-w-lg max-h-[85vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2 text-white">
<Trash2 className="h-5 w-5 text-red-400" />
Select Content to Delete
</DialogTitle>
<DialogDescription className="text-gray-400">
Select the specific items you want to request deletion for.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col space-y-3 py-2">
{/* Filter tabs */}
<div className="flex gap-1 overflow-x-auto pb-2 flex-shrink-0">
{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 (
<button
key={String(tab.kind)}
onClick={() => setFilterKind(tab.kind)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-colors ${
isActive
? 'bg-gray-700 text-white'
: 'bg-gray-800/50 text-gray-400 hover:bg-gray-800'
}`}
>
<Icon className={`h-3 w-3 ${isActive ? tab.color : ''}`} />
{tab.label}
<span className={`${isActive ? 'text-gray-300' : 'text-gray-500'}`}>({count})</span>
</button>
);
})}
</div>
{/* Quick actions */}
<div className="flex items-center justify-between flex-shrink-0">
<div className="flex gap-2 text-xs">
<button onClick={selectAllVisible} className="text-[#ccff00] hover:underline">
Select All {filterKind !== 'all' ? 'Visible' : ''}
</button>
<span className="text-gray-600">|</span>
<button onClick={selectNone} className="text-gray-400 hover:underline">Clear</button>
</div>
<span className="text-xs text-gray-500">
{selectedCount} selected
</span>
</div>
{/* Event list */}
<div className="flex-1 overflow-y-auto space-y-1.5 pr-1 min-h-0">
{displayEvents.length === 0 ? (
<div className="text-center py-8 text-gray-500">
No items in this category
</div>
) : (
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 (
<label
key={event.id}
className={`flex items-center gap-2 p-2.5 rounded-lg border transition-colors cursor-pointer ${
isChecked
? 'bg-red-950/30 border-red-500/50'
: 'bg-black/30 border-gray-800 hover:border-gray-700'
}`}
>
<Checkbox
checked={isChecked}
onCheckedChange={() => toggleEvent(event.id)}
className="border-gray-600 data-[state=checked]:bg-red-500 data-[state=checked]:border-red-500 flex-shrink-0"
/>
<div className={`p-1.5 rounded ${config.color} bg-gray-800 flex-shrink-0`}>
<Icon className="h-3.5 w-3.5" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{getEventTitle(event)}</p>
<p className="text-[10px] text-gray-500">
{config.label} • {formatDistanceToNow(event.created_at * 1000, { addSuffix: true })}
</p>
</div>
</label>
);
})
)}
</div>
{/* Warning */}
<div className="p-2.5 rounded-lg bg-yellow-950/50 border border-yellow-500/30 flex-shrink-0">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-yellow-500 flex-shrink-0 mt-0.5" />
<p className="text-yellow-200 text-xs">
<strong>Important:</strong> Nostr is decentralized. Relays <em>should</em> honor deletion requests,
but some may not. This is not a guaranteed permanent deletion.
</p>
</div>
</div>
</div>
<DialogFooter className="flex-shrink-0 flex-col sm:flex-row gap-2 pt-2 border-t border-gray-800">
<Button
variant="ghost"
onClick={() => setOpen(false)}
className="border-gray-700"
>
Cancel
</Button>
<Button
onClick={handleDelete}
disabled={selectedCount === 0 || isDeleting}
className="bg-red-500 hover:bg-red-600 text-white"
>
{isDeleting ? (
'Deleting...'
) : (
<>
Delete {selectedCount} {selectedCount === 1 ? 'Item' : 'Items'}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
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<number>();
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 (
<Card className="border-dashed">
<CardContent className="py-12 text-center">
<Database className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">Login Required</h3>
<p className="text-muted-foreground mb-6">
Log in to view and manage your data.
</p>
<LoginArea className="justify-center" />
</CardContent>
</Card>
);
}
const displayedEvents = showAllActivity ? userEvents : userEvents?.slice(0, 10);
return (
<div className="space-y-6">
{/* Header Card */}
<Card className="bg-gradient-to-br from-[#ccff00]/10 to-[#00ff00]/5 border-2 border-[#ccff00]/30">
<CardHeader className="pb-4">
<CardTitle className="flex items-center gap-2 text-white">
<Database className="h-5 w-5 text-[#ccff00]" />
My Data
</CardTitle>
<CardDescription className="text-gray-300">
Your content on the Nostr network. You own this data - export it anytime or request deletion.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 sm:space-y-6">
{/* Stats Grid - responsive 2x4 on mobile, 4x2 on desktop */}
<div className="space-y-2 sm:space-y-3">
{/* Row 1: Main content */}
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
<StatCard icon={MapPin} label="Spots" count={stats.spots} color="text-[#ccff00]" isLoading={isLoading} />
<StatCard icon={Calendar} label="Events" count={stats.events} color="text-[#ff0040]" isLoading={isLoading} />
<StatCard icon={Store} label="Shops" count={stats.shops} color="text-purple-400" isLoading={isLoading} />
<StatCard icon={ShoppingBag} label="Listings" count={stats.listings} color="text-orange-400" isLoading={isLoading} />
</div>
{/* Row 2: Social & Lab */}
<div className="grid grid-cols-4 gap-1.5 sm:gap-2">
<StatCard icon={MessageSquare} label="Posts" count={stats.posts + stats.comments} color="text-blue-400" isLoading={isLoading} />
<StatCard icon={GraduationCap} label="Lab" count={stats.labProfiles} color="text-green-400" isLoading={isLoading} subtitle="Coach/Filmer" />
<StatCard icon={Heart} label="Reactions" count={stats.reactions} color="text-pink-400" isLoading={isLoading} />
<StatCard icon={Zap} label="Zaps" count={stats.zapsReceived} color="text-yellow-400" isLoading={isLoading} subtitle="Received" />
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-3">
<Button
onClick={handleExportData}
disabled={isLoading || !userEvents?.length}
className="flex-1 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold"
>
<Download className="h-4 w-4 mr-2" />
Export All Data
</Button>
<SelectiveDeletionDialog
userEvents={userEvents || []}
onDeleteSelected={handleDeleteSelected}
isDeleting={isDeleting}
/>
</div>
</CardContent>
</Card>
{/* Recent Activity */}
<Card className="bg-black/50 border-gray-800">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg text-white">Recent Activity</CardTitle>
{userEvents && userEvents.length > 10 && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowAllActivity(!showAllActivity)}
className="text-[#ccff00] hover:text-[#ccff00]/80"
>
{showAllActivity ? 'Show Less' : `View All (${userEvents.length})`}
<ExternalLink className="h-3 w-3 ml-1" />
</Button>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : !userEvents?.length ? (
<div className="text-center py-8">
<Database className="h-10 w-10 mx-auto mb-3 text-gray-600" />
<p className="text-gray-400">No data published yet</p>
<p className="text-gray-500 text-sm mt-1">
Start by <Link to="/create-spot" className="text-[#ccff00] hover:underline">adding a spot</Link>!
</p>
</div>
) : (
<div className="space-y-2 max-h-[500px] overflow-y-auto pr-2">
{displayedEvents?.map((event) => (
<EventRow key={event.id} event={event} onDelete={handleDeleteEvent} />
))}
</div>
)}
</CardContent>
</Card>
{/* Data Ownership Info */}
<Card className="bg-blue-950/30 border-blue-500/30">
<CardHeader className="pb-3">
<CardTitle className="text-blue-400 text-base">About Your Data</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-gray-300">
<p>
<strong className="text-white">You own your data.</strong> All content is signed with your cryptographic keys
and stored on Nostr relays. No company controls it.
</p>
<ul className="space-y-2 ml-4">
<li className="flex items-start gap-2">
<span className="text-[#ccff00]">✓</span>
<span><strong>Export anytime</strong> - Download all your events as JSON</span>
</li>
<li className="flex items-start gap-2">
<span className="text-[#ccff00]">✓</span>
<span><strong>Portable</strong> - Use your keys in any Nostr app</span>
</li>
<li className="flex items-start gap-2">
<span className="text-[#ccff00]">✓</span>
<span><strong>Selective deletion</strong> - Choose exactly what to delete</span>
</li>
<li className="flex items-start gap-2">
<span className="text-yellow-400">⚠</span>
<span><strong>Decentralized caveat</strong> - Some relays may retain data despite deletion requests</span>
</li>
</ul>
</CardContent>
</Card>
</div>
);
}