The "Select Content to Delete" dialog was broken on mobile:
- Filter pills had no flex-shrink-0, so they squashed and their labels overlapped
("All (504Spots (Shops…"). Added shrink-0 + min-h-0 so the row scrolls cleanly.
- The global mobile `button { min-height:44px }` rule inflated the checkboxes
into giant squares. Added min-h-0/min-w-0 (and an explicit h-4 w-4).
- The per-row delete on Recent Activity was hover-only (invisible on touch);
it's now always visible on mobile and compact.
795 lines
30 KiB
TypeScript
795 lines
30 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 '@/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<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 { 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 (
|
|
<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)}
|
|
aria-label={t('settingsUi.myData.requestDeletion')}
|
|
className="min-h-0 min-w-0 opacity-100 transition-opacity text-red-400 hover:text-red-300 hover:bg-red-500/10 sm:opacity-0 sm:group-hover:opacity-100"
|
|
>
|
|
<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 { t } = useTranslation();
|
|
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: 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 (
|
|
<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 ? t('settingsUi.myData.deleting') : t('settingsUi.myData.requestDeletion')}
|
|
</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" />
|
|
{t('settingsUi.myData.selectContentTitle')}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-gray-400">
|
|
{t('settingsUi.myData.selectContentDesc')}
|
|
</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 shrink-0 min-h-0 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="min-h-0 text-[#ccff00] hover:underline">
|
|
{filterKind !== 'all' ? t('settingsUi.myData.selectAllVisible') : t('settingsUi.myData.selectAll')}
|
|
</button>
|
|
<span className="text-gray-600">|</span>
|
|
<button onClick={selectNone} className="min-h-0 text-gray-400 hover:underline">{t('common.clear')}</button>
|
|
</div>
|
|
<span className="text-xs text-gray-500">
|
|
{t('settingsUi.myData.selectedCount', { count: selectedCount })}
|
|
</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">
|
|
{t('settingsUi.myData.noItemsCategory')}
|
|
</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="h-4 w-4 min-h-0 min-w-0 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>{t('settingsUi.myData.decentralizedStrong')}</strong> {t('settingsUi.myData.decentralizedNote')}
|
|
</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"
|
|
>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
<Button
|
|
onClick={handleDelete}
|
|
disabled={selectedCount === 0 || isDeleting}
|
|
className="bg-red-500 hover:bg-red-600 text-white"
|
|
>
|
|
{isDeleting ? (
|
|
t('settingsUi.myData.deleting')
|
|
) : (
|
|
<>
|
|
{t('settingsUi.myData.deleteCount', { count: selectedCount, s: selectedCount === 1 ? '' : 's' })}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
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<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: 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 (
|
|
<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">{t('settingsUi.myData.loginRequired')}</h3>
|
|
<p className="text-muted-foreground mb-6">
|
|
{t('settingsUi.myData.loginRequiredDesc')}
|
|
</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]" />
|
|
{t('settingsUi.myData.title')}
|
|
</CardTitle>
|
|
<CardDescription className="text-gray-300">
|
|
{t('settingsUi.myData.desc')}
|
|
</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={t('settingsUi.myData.spots')} count={stats.spots} color="text-[#ccff00]" isLoading={isLoading} />
|
|
<StatCard icon={Calendar} label={t('settingsUi.myData.events')} count={stats.events} color="text-[#ff0040]" isLoading={isLoading} />
|
|
<StatCard icon={Store} label={t('settingsUi.myData.shops')} count={stats.shops} color="text-purple-400" isLoading={isLoading} />
|
|
<StatCard icon={ShoppingBag} label={t('settingsUi.myData.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={t('settingsUi.myData.posts')} count={stats.posts + stats.comments} color="text-blue-400" isLoading={isLoading} />
|
|
<StatCard icon={GraduationCap} label={t('settingsUi.myData.lab')} count={stats.labProfiles} color="text-green-400" isLoading={isLoading} subtitle={t('settingsUi.myData.coachFilmer')} />
|
|
<StatCard icon={Heart} label={t('settingsUi.myData.reactions')} count={stats.reactions} color="text-pink-400" isLoading={isLoading} />
|
|
<StatCard icon={Zap} label={t('settingsUi.myData.zaps')} count={stats.zapsReceived} color="text-yellow-400" isLoading={isLoading} subtitle={t('settingsUi.myData.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" />
|
|
{t('settingsUi.myData.exportAll')}
|
|
</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">{t('settingsUi.myData.recentActivity')}</CardTitle>
|
|
{userEvents && userEvents.length > 10 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setShowAllActivity(!showAllActivity)}
|
|
className="text-[#ccff00] hover:text-[#ccff00]/80"
|
|
>
|
|
{showAllActivity ? t('common.showLess') : t('settingsUi.myData.viewAll', { count: 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">{t('settingsUi.myData.noDataPublished')}</p>
|
|
<p className="text-gray-500 text-sm mt-1">
|
|
{t('settingsUi.myData.startBy')} <Link to="/create-spot" className="text-[#ccff00] hover:underline">{t('settingsUi.myData.addingASpot')}</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">{t('settingsUi.myData.aboutYourData')}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3 text-sm text-gray-300">
|
|
<p>
|
|
<strong className="text-white">{t('settingsUi.myData.ownDataStrong')}</strong> {t('settingsUi.myData.ownData')}
|
|
</p>
|
|
<ul className="space-y-2 ml-4">
|
|
<li className="flex items-start gap-2">
|
|
<span className="text-[#ccff00]">✓</span>
|
|
<span><strong>{t('settingsUi.myData.exportAnytimeStrong')}</strong> {t('settingsUi.myData.exportAnytime')}</span>
|
|
</li>
|
|
<li className="flex items-start gap-2">
|
|
<span className="text-[#ccff00]">✓</span>
|
|
<span><strong>{t('settingsUi.myData.portableStrong')}</strong> {t('settingsUi.myData.portable')}</span>
|
|
</li>
|
|
<li className="flex items-start gap-2">
|
|
<span className="text-[#ccff00]">✓</span>
|
|
<span><strong>{t('settingsUi.myData.selectiveStrong')}</strong> {t('settingsUi.myData.selective')}</span>
|
|
</li>
|
|
<li className="flex items-start gap-2">
|
|
<span className="text-yellow-400">⚠</span>
|
|
<span><strong>{t('settingsUi.myData.caveatStrong')}</strong> {t('settingsUi.myData.caveat')}</span>
|
|
</li>
|
|
</ul>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|