SkateSpots/src/pages/CollectionDetailPage.tsx
SkateSpots 0b796440f1 feat(i18n): Spanish batch 2 — nav/chrome, events/shops/tours, crews/DMs, settings/auth, wallet/stash/newsletter/stats
~3,470 new keys (5,770 total, EN=ES), ~115 more files wired.
- nav + chromeUi: header, More menu, burger menu, footer, map toolbar widgets
  (sort/trust/weather), spot-type label helper.
- placesUi: events, shops, tours, food. socialUi: crews, DMs, notifications,
  collectives, crew chat/coordinator. settingsUi: settings, auth/signup,
  onboarding, profile edit, legal. walletUi: wallet/Cashu, stash, mints,
  app shop, newsletter, community stats, leaderboards, profile.
- Fixed 738 usages where an agent dropped the socialUi. prefix (caught by check:i18n).
2026-10-02 10:11:06 +07:00

382 lines
14 KiB
TypeScript

import { useParams, Link, useNavigate } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { PageError } from '@/components/ErrorState';
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
import { SpotMap } from '@/components/SpotMap';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useRelays } from '@/lib/relays';
import { useSpotCollection, useRemoveSpotFromCollection, useDeleteSpotCollection } from '@/hooks/useSpotCollections';
import type { NostrEvent } from '@nostrify/nostrify';
import { SKATE_SPOT_KIND, parseSkateSpot, type SkateSpot } from '@/lib/types';
import { ArrowLeft, FolderOpen, MapPin, Calendar, Trash2, X, Share2, Loader2, AlertTriangle } from 'lucide-react';
import NotFound from './NotFound';
import { useState } from 'react';
function CollectionDetailSkeleton() {
return (
<>
<div className="container px-4 py-8">
<Skeleton className="h-6 w-32 mb-6" />
<div className="space-y-6">
<Skeleton className="h-12 w-3/4" />
<Skeleton className="h-6 w-1/2" />
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<SpotCardSkeleton key={i} />
))}
</div>
</div>
</div>
</>
);
}
export default function CollectionDetailPage() {
const { t } = useTranslation();
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
const { data: collection, isLoading, error, refetch } = useSpotCollection(identifier || '', pubkey);
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { read } = useRelays();
const { removeSpotFromCollection, isPending: isRemoving } = useRemoveSpotFromCollection();
const { deleteCollection, isPending: isDeleting } = useDeleteSpotCollection();
const { toast } = useToast();
const navigate = useNavigate();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [removingSpotId, setRemovingSpotId] = useState<string | null>(null);
const isOwner = user && collection && user.pubkey === collection.pubkey;
useSeoMeta({
title: collection ? `${collection.title} - SkateSpots Collection` : 'Spot Collection - SkateSpots',
description: collection?.description || 'A curated collection of skate spots',
});
// Fetch all spots referenced in the collection
const { data: spots, isLoading: isSpotsLoading } = useQuery({
queryKey: ['collection-spots', collection?.id, collection?.spotRefs],
queryFn: async (c) => {
if (!collection || collection.spotRefs.length === 0) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(15000)]);
const readRelays = read;
// Parse spot references to build filters
const spotFilters = collection.spotRefs.map((ref) => {
const [kind, pubkey, d] = ref.split(':');
return { kind: parseInt(kind), pubkey, d };
}).filter(f => f.kind === SKATE_SPOT_KIND);
if (spotFilters.length === 0) return [];
const allEvents: NostrEvent[] = [];
// Query each relay for all spots
for (const relayUrl of readRelays) {
try {
const relay = nostr.relay(relayUrl);
// Query each spot individually (addressable events need specific filters)
for (const filter of spotFilters) {
const events = await relay.query(
[{
kinds: [SKATE_SPOT_KIND],
authors: [filter.pubkey],
'#d': [filter.d],
limit: 1,
}],
{ signal }
);
allEvents.push(...events);
}
} catch (error) {
console.warn(`[CollectionDetail] Failed to query relay ${relayUrl}:`, error);
}
}
// Deduplicate by event id
const eventMap = new Map<string, typeof allEvents[0]>();
for (const event of allEvents) {
const d = event.tags.find(([name]: [string]) => name === 'd')?.[1];
if (d) {
const key = `${event.pubkey}:${event.kind}:${d}`;
const existing = eventMap.get(key);
if (!existing || event.created_at > existing.created_at) {
eventMap.set(key, event);
}
}
}
const spots: SkateSpot[] = [];
for (const event of Array.from(eventMap.values())) {
const spot = parseSkateSpot(event);
if (spot) spots.push(spot);
}
return spots;
},
enabled: !!collection && collection.spotRefs.length > 0,
staleTime: 30000,
});
const handleRemoveSpot = async (spot: SkateSpot) => {
if (!collection || !isOwner) return;
setRemovingSpotId(spot.id);
try {
await removeSpotFromCollection({
collection,
spotPubkey: spot.pubkey,
spotIdentifier: spot.identifier,
});
toast({
title: t('walletUi.collection.spotRemoved'),
description: t('walletUi.collection.spotRemovedDesc', { title: spot.title }),
});
} catch (error) {
toast({
title: t('walletUi.collection.removeFailed'),
description: error instanceof Error ? error.message : t('walletUi.addToCollection.somethingWrong'),
variant: 'destructive',
});
} finally {
setRemovingSpotId(null);
}
};
const handleDeleteCollection = async () => {
if (!collection) return;
try {
await deleteCollection(collection);
toast({
title: t('walletUi.collection.deleted'),
description: t('walletUi.collection.deletedDesc', { title: collection.title }),
});
navigate('/collections');
} catch (error) {
toast({
title: t('walletUi.collection.deleteFailed'),
description: error instanceof Error ? error.message : t('walletUi.addToCollection.somethingWrong'),
variant: 'destructive',
});
}
setShowDeleteDialog(false);
};
const handleShare = async () => {
if (navigator.share) {
try {
await navigator.share({
title: collection?.title,
text: collection?.description || t('walletUi.collection.shareText', { title: collection?.title ?? '' }),
url: window.location.href,
});
} catch {
// User cancelled or share failed
}
} else {
navigator.clipboard.writeText(window.location.href);
toast({
title: t('walletUi.collection.linkCopied'),
description: t('walletUi.collection.linkCopiedDesc'),
});
}
};
if (isLoading) {
return <CollectionDetailSkeleton />;
}
if (error) {
return <PageError onRetry={() => refetch()} />;
}
if (!collection) {
return <NotFound />;
}
return (
<>
<div className="container px-4 py-4 sm:py-8">
{/* Back link */}
<Link
to="/collections"
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-4 sm:mb-6 text-sm"
>
<ArrowLeft className="h-4 w-4 mr-1.5 sm:mr-2" />
{t('walletUi.collection.back')}
</Link>
{/* Header */}
<div className="flex flex-col gap-4 mb-6 sm:mb-8">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 sm:gap-3 mb-1 sm:mb-2">
<FolderOpen className="h-6 w-6 sm:h-8 sm:w-8 text-[#ccff00] flex-shrink-0" />
<h1 className="text-xl sm:text-3xl font-black uppercase tracking-wider text-white truncate">
{collection.title}
</h1>
</div>
{collection.description && (
<p className="text-gray-400 mt-1 sm:mt-2 max-w-2xl text-sm sm:text-base">
{collection.description}
</p>
)}
</div>
<div className="flex items-center gap-1.5 sm:gap-2 flex-shrink-0">
<Button
variant="outline"
size="icon"
onClick={handleShare}
className="border-gray-700 h-9 w-9 sm:h-10 sm:w-10"
>
<Share2 className="h-4 w-4" />
</Button>
{isOwner && (
<Button
variant="outline"
size="icon"
onClick={() => setShowDeleteDialog(true)}
className="border-red-500/50 hover:bg-red-500/10 hover:text-red-500 h-9 w-9 sm:h-10 sm:w-10"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2 sm:gap-4 text-xs sm:text-sm text-gray-500">
<Badge variant="outline" className="border-[#ccff00]/50 text-[#ccff00]">
<MapPin className="h-2.5 w-2.5 sm:h-3 sm:w-3 mr-1" />
{t('walletUi.addToCollection.spotCount', { count: collection.spotRefs.length })}
</Badge>
<span className="flex items-center gap-1">
<Calendar className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
{t('walletUi.collection.created', { date: new Date(collection.createdAt * 1000).toLocaleDateString() })}
</span>
</div>
</div>
{/* Map showing all spots */}
{spots && spots.length > 0 && (
<Card className="mb-6 sm:mb-8 border-2 border-[#ccff00]/30">
<CardHeader className="p-3 sm:p-6 pb-2 sm:pb-4">
<CardTitle className="text-base sm:text-lg flex items-center gap-2">
<MapPin className="h-4 w-4 sm:h-5 sm:w-5 text-[#ccff00]" />
{t('walletUi.collection.map')}
</CardTitle>
</CardHeader>
<CardContent className="p-3 sm:p-6 pt-0">
<div className="aspect-[4/3] sm:aspect-[2/1] rounded-lg overflow-hidden">
<SpotMap
spots={spots}
className="h-full"
zoom={5}
/>
</div>
</CardContent>
</Card>
)}
{/* Spots grid */}
{isSpotsLoading ? (
<div className="grid gap-4 sm:gap-6 grid-cols-2 lg:grid-cols-3">
{Array.from({ length: collection.spotRefs.length || 3 }).map((_, i) => (
<SpotCardSkeleton key={i} />
))}
</div>
) : spots && spots.length > 0 ? (
<div className="grid gap-4 sm:gap-6 grid-cols-2 lg:grid-cols-3">
{spots.map((spot) => (
<div key={spot.id} className="relative group">
<SpotCard spot={spot} />
{isOwner && (
<Button
size="icon"
variant="destructive"
className="absolute top-2 right-2 h-8 w-8 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity z-10 touch-manipulation"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleRemoveSpot(spot);
}}
disabled={isRemoving}
>
{removingSpotId === spot.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<X className="h-4 w-4" />
)}
</Button>
)}
</div>
))}
</div>
) : (
<Card className="border-dashed border-2 border-gray-700">
<CardContent className="py-16 text-center">
<MapPin className="h-16 w-16 mx-auto mb-4 text-gray-600" />
<h2 className="text-xl font-bold mb-2">{t('walletUi.collection.empty')}</h2>
<p className="text-gray-400 mb-6">
{isOwner
? t('walletUi.collection.emptyOwner')
: t('walletUi.collection.emptyOther')}
</p>
{isOwner && (
<Link to="/map">
<Button className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase">
{t('walletUi.collection.browseSpots')}
</Button>
</Link>
)}
</CardContent>
</Card>
)}
</div>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent className="bg-black border-2 border-red-500/50 mx-4 max-w-[calc(100vw-2rem)] sm:max-w-lg">
<AlertDialogHeader>
<AlertDialogTitle className="text-white flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-500" />
{t('walletUi.collection.deleteTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="text-gray-400 text-sm">
{t('walletUi.collection.deleteDesc', { title: collection.title })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex-col sm:flex-row gap-2 sm:gap-0">
<AlertDialogCancel className="border-gray-700 h-11 sm:h-auto">{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteCollection}
disabled={isDeleting}
className="bg-red-500 hover:bg-red-600 text-white h-11 sm:h-auto"
>
{isDeleting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t('walletUi.collection.deleting')}
</>
) : (
t('common.delete')
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}