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 { 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 (
<>
{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 { 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(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();
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 && (
)}
{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 && (
)}
))}
) : (
No spots in this collection
{isOwner
? 'Add spots to your collection by clicking "Save to Collection" on any spot'
: 'This collection is empty'}
{isOwner && (
)}
)}
{/* Delete Confirmation Dialog */}
Delete Collection?
This will permanently delete "{collection.title}". The spots themselves won't be affected.
Cancel
{isDeleting ? (
<>
Deleting...
>
) : (
'Delete'
)}
>
);
}