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 { Layout } from '@/components/Layout'; 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 { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAppContext } from '@/hooks/useAppContext'; 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 ( {Array.from({ length: 6 }).map((_, i) => ( ))} ); } export default function CollectionDetailPage() { const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>(); const { data: collection, isLoading, error, refetch } = useSpotCollection(identifier || '', pubkey); const { user } = useCurrentUser(); const { nostr } = useNostr(); const { config } = useAppContext(); 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(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 = config.relayMetadata.relays .filter(r => r.read) .map(r => r.url); // 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(); 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: 'Spot removed', description: `"${spot.title}" has been removed from this collection`, }); } catch (error) { toast({ title: 'Failed to remove spot', description: error instanceof Error ? error.message : 'Something went wrong', variant: 'destructive', }); } finally { setRemovingSpotId(null); } }; const handleDeleteCollection = async () => { if (!collection) return; try { await deleteCollection(collection); toast({ title: 'Collection deleted', description: `"${collection.title}" has been removed`, }); navigate('/collections'); } catch (error) { toast({ title: 'Failed to delete collection', description: error instanceof Error ? error.message : 'Something went wrong', variant: 'destructive', }); } setShowDeleteDialog(false); }; const handleShare = async () => { if (navigator.share) { try { await navigator.share({ title: collection?.title, text: collection?.description || `Check out this spot collection: ${collection?.title}`, url: window.location.href, }); } catch { // User cancelled or share failed } } else { navigator.clipboard.writeText(window.location.href); toast({ title: 'Link copied!', description: 'Collection link has been copied to clipboard', }); } }; if (isLoading) { return ; } if (error) { return refetch()} />; } if (!collection) { return ; } return ( {/* Back link */} Back to Collections {/* Header */} {collection.title} {collection.description && ( {collection.description} )} {isOwner && ( 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" > )} {collection.spotRefs.length} spot{collection.spotRefs.length !== 1 ? 's' : ''} Created {new Date(collection.createdAt * 1000).toLocaleDateString()} {/* Map showing all spots */} {spots && spots.length > 0 && ( Collection Map )} {/* Spots grid */} {isSpotsLoading ? ( {Array.from({ length: collection.spotRefs.length || 3 }).map((_, i) => ( ))} ) : spots && spots.length > 0 ? ( {spots.map((spot) => ( {isOwner && ( { e.preventDefault(); e.stopPropagation(); handleRemoveSpot(spot); }} disabled={isRemoving} > {removingSpotId === spot.id ? ( ) : ( )} )} ))} ) : ( No spots in this collection {isOwner ? 'Add spots to your collection by clicking "Save to Collection" on any spot' : 'This collection is empty'} {isOwner && ( Browse Spots )} )} {/* Delete Confirmation Dialog */} Delete Collection? This will permanently delete "{collection.title}". The spots themselves won't be affected. Cancel {isDeleting ? ( <> Deleting... > ) : ( 'Delete' )} ); }
{collection.description}
{isOwner ? 'Add spots to your collection by clicking "Save to Collection" on any spot' : 'This collection is empty'}