So Local relays are used and Blocked relays are excluded EVERYWHERE, not just in the NPool routers: - useListingReports, useSellerInfo, useSkateRelays (read+write), useZaps (NIP-57 relays tag), usePurchasePro, CollectionDetailPage, CrewForm, useDirectMessages write fallback - AppProvider's empty-relays defaults check is the only intentional direct reader - AGENTS.md updated Gate: 101 tests, 0 errors/warnings, boot smoke green.
381 lines
14 KiB
TypeScript
381 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 { 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 { 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 (
|
|
<Layout>
|
|
<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>
|
|
</Layout>
|
|
);
|
|
}
|
|
|
|
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<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: '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 <CollectionDetailSkeleton />;
|
|
}
|
|
|
|
if (error) {
|
|
return <PageError onRetry={() => refetch()} />;
|
|
}
|
|
|
|
if (!collection) {
|
|
return <NotFound />;
|
|
}
|
|
|
|
return (
|
|
<Layout>
|
|
<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" />
|
|
Back to Collections
|
|
</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" />
|
|
{collection.spotRefs.length} spot{collection.spotRefs.length !== 1 ? 's' : ''}
|
|
</Badge>
|
|
<span className="flex items-center gap-1">
|
|
<Calendar className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
|
Created {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]" />
|
|
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">No spots in this collection</h2>
|
|
<p className="text-gray-400 mb-6">
|
|
{isOwner
|
|
? 'Add spots to your collection by clicking "Save to Collection" on any spot'
|
|
: 'This collection is empty'}
|
|
</p>
|
|
{isOwner && (
|
|
<Link to="/map">
|
|
<Button className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase">
|
|
Browse Spots
|
|
</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" />
|
|
Delete Collection?
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription className="text-gray-400 text-sm">
|
|
This will permanently delete "{collection.title}". The spots themselves won't be affected.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter className="flex-col sm:flex-row gap-2 sm:gap-0">
|
|
<AlertDialogCancel className="border-gray-700 h-11 sm:h-auto">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" />
|
|
Deleting...
|
|
</>
|
|
) : (
|
|
'Delete'
|
|
)}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</Layout>
|
|
);
|
|
}
|