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>
792 lines
29 KiB
TypeScript
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>
|
|
);
|
|
}
|