SkateSpots/src/pages/CollectionDetailPage.tsx
shakespeare.diy e29d35f1e4 fix: restore full skate-spots source tree from misplaced src/src/ copy
The entire skate-spots codebase was accidentally copied into src/src/ and
public/public/ instead of src/ and public/ directly. This left the real
src/ directory as blank MKStack template stubs, explaining why the landing
page showed the default template instead of SkateSpots.

Changes made:
- Copied all files from src/src/ up to src/ (App.tsx, AppRouter.tsx, main.tsx,
  all pages, components, hooks, contexts, lib, i18n, test)
- Copied all files from public/public/ up to public/ (images, manifest, etc.)
- Deleted src/src/ and public/public/ nested directories
- Removed HelloWorld.tsx template stub from pages/
- Retained the previously fixed index.css (Tailwind v3 directives)

Full skate-spots feature set is now active:
- All 30+ routes (map, events, spots, coaches, marketplace, wallet, etc.)
- NWCProvider, LanguageProvider, PWAInstallPrompt, AmberCallbackHandler
- Skate community relays (goskatespots.nostr1.com, wotr.relatr.xyz, etc.)
- Outfit Variable font, Amber pre-check in main.tsx
- All hooks (useSkateSpots, useCoaches, useZaps, etc.)
- i18n support (en/es)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-15 22:18:28 +07:00

401 lines
14 KiB
TypeScript

import { useMemo } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { useNostr } from '@nostrify/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Layout } from '@/components/Layout';
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 { 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 } = 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 queryClient = useQueryClient();
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 = 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: any[] = [];
// 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 || !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>
);
}