SkateSpots/src/pages/SpotCollectionsPage.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

500 lines
18 KiB
TypeScript

import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { Layout } from '@/components/Layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
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 {
useMySpotCollections,
useCreateSpotCollection,
useDeleteSpotCollection,
useUpdateSpotCollection,
} from '@/hooks/useSpotCollections';
import type { SpotCollection } from '@/lib/types';
import {
FolderPlus,
FolderOpen,
MapPin,
Plus,
Loader2,
Pencil,
Trash2,
Calendar,
ArrowRight,
Bookmark,
} from 'lucide-react';
import { cn } from '@/lib/utils';
function CollectionCard({
collection,
onEdit,
onDelete,
}: {
collection: SpotCollection;
onEdit: (collection: SpotCollection) => void;
onDelete: (collection: SpotCollection) => void;
}) {
return (
<Link to={`/collections/${collection.pubkey}/${collection.identifier}`}>
<Card className={cn(
"group overflow-hidden hover:shadow-2xl transition-all duration-300 border-2 bg-black/90 backdrop-blur active:scale-[0.98]",
"border-[#ccff00]/30 hover:border-[#ccff00]/70 hover:shadow-[#ccff00]/20"
)}>
{/* Image/Header */}
<div className="relative aspect-video overflow-hidden bg-gradient-to-br from-[#ccff00]/20 to-[#00ff88]/20">
{collection.image ? (
<img
src={collection.image}
alt={collection.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<FolderOpen className="h-12 w-12 sm:h-16 sm:w-16 text-[#ccff00]/40" />
</div>
)}
{/* Spot count badge */}
<div className="absolute top-2 left-2 sm:top-3 sm:left-3">
<Badge className="bg-black/80 text-[#ccff00] backdrop-blur-sm border border-[#ccff00]/50 font-bold text-xs">
<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>
</div>
{/* Action buttons - always visible on mobile, hover on desktop */}
<div className="absolute top-2 right-2 sm:top-3 sm:right-3 flex gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
<Button
size="icon"
variant="ghost"
className="h-8 w-8 sm:h-8 sm:w-8 bg-black/80 hover:bg-black text-white touch-manipulation"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onEdit(collection);
}}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 sm:h-8 sm:w-8 bg-black/80 hover:bg-red-500/80 text-white touch-manipulation"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelete(collection);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<CardHeader className="p-3 sm:p-4 pb-1 sm:pb-2">
<h3 className="font-bold text-sm sm:text-lg leading-tight transition-colors line-clamp-1 uppercase tracking-wide text-white group-hover:text-[#ccff00]">
{collection.title}
</h3>
{collection.description && (
<p className="text-xs sm:text-sm text-gray-400 line-clamp-2">
{collection.description}
</p>
)}
</CardHeader>
<CardContent className="p-3 sm:p-4 pt-0 sm:pt-0 pb-3 sm:pb-4">
<div className="flex items-center justify-between text-[10px] sm:text-xs text-gray-500">
<span className="flex items-center gap-1">
<Calendar className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
{new Date(collection.createdAt * 1000).toLocaleDateString()}
</span>
<span className="flex items-center gap-1 text-[#ccff00] sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
View
<ArrowRight className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
</span>
</div>
</CardContent>
</Card>
</Link>
);
}
function CollectionCardSkeleton() {
return (
<Card className="overflow-hidden border-2 border-gray-800">
<Skeleton className="aspect-video w-full" />
<CardHeader className="pb-2">
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-full" />
</CardHeader>
<CardContent className="pb-4">
<Skeleton className="h-4 w-1/3" />
</CardContent>
</Card>
);
}
export default function SpotCollectionsPage() {
const { user } = useCurrentUser();
const { data: collections, isLoading } = useMySpotCollections();
const { createCollection, isPending: isCreating } = useCreateSpotCollection();
const { deleteCollection, isPending: isDeleting } = useDeleteSpotCollection();
const { updateCollection, isPending: isUpdating } = useUpdateSpotCollection();
const { toast } = useToast();
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [showEditDialog, setShowEditDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [selectedCollection, setSelectedCollection] = useState<SpotCollection | null>(null);
const [newTitle, setNewTitle] = useState('');
const [newDescription, setNewDescription] = useState('');
const [editTitle, setEditTitle] = useState('');
const [editDescription, setEditDescription] = useState('');
useSeoMeta({
title: 'My Spot Collections - SkateSpots',
description: 'Organize your favorite skate spots into personalized collections',
});
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!newTitle.trim()) return;
try {
await createCollection({
title: newTitle.trim(),
description: newDescription.trim() || undefined,
});
toast({
title: 'Collection created!',
description: `"${newTitle}" is ready for spots`,
});
setNewTitle('');
setNewDescription('');
setShowCreateDialog(false);
} catch (error) {
toast({
title: 'Failed to create collection',
description: error instanceof Error ? error.message : 'Something went wrong',
variant: 'destructive',
});
}
};
const handleEdit = (collection: SpotCollection) => {
setSelectedCollection(collection);
setEditTitle(collection.title);
setEditDescription(collection.description || '');
setShowEditDialog(true);
};
const handleEditSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedCollection || !editTitle.trim()) return;
try {
await updateCollection({
collection: selectedCollection,
title: editTitle.trim(),
description: editDescription.trim() || undefined,
});
toast({
title: 'Collection updated!',
description: 'Your changes have been saved',
});
setShowEditDialog(false);
setSelectedCollection(null);
} catch (error) {
toast({
title: 'Failed to update collection',
description: error instanceof Error ? error.message : 'Something went wrong',
variant: 'destructive',
});
}
};
const handleDelete = (collection: SpotCollection) => {
setSelectedCollection(collection);
setShowDeleteDialog(true);
};
const handleDeleteConfirm = async () => {
if (!selectedCollection) return;
try {
await deleteCollection(selectedCollection);
toast({
title: 'Collection deleted',
description: `"${selectedCollection.title}" has been removed`,
});
setShowDeleteDialog(false);
setSelectedCollection(null);
} catch (error) {
toast({
title: 'Failed to delete collection',
description: error instanceof Error ? error.message : 'Something went wrong',
variant: 'destructive',
});
}
};
return (
<Layout>
<div className="container px-4 py-4 sm:py-8">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 sm:mb-8">
<div className="min-w-0">
<h1 className="text-xl sm:text-3xl font-black uppercase tracking-wider flex items-center gap-2 sm:gap-3">
<Bookmark className="h-6 w-6 sm:h-8 sm:w-8 text-[#ccff00] flex-shrink-0" />
<span className="neon-text truncate">MY COLLECTIONS</span>
</h1>
<p className="text-gray-400 mt-1 sm:mt-2 text-sm sm:text-base">
Organize your favorite spots into personalized lists
</p>
</div>
{user && (
<Button
onClick={() => setShowCreateDialog(true)}
className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase gap-2 h-11 sm:h-auto w-full sm:w-auto flex-shrink-0"
>
<Plus className="h-4 w-4" />
New Collection
</Button>
)}
</div>
{/* Not logged in state */}
{!user && (
<Card className="border-dashed border-2 border-gray-700">
<CardContent className="py-16 text-center">
<FolderPlus className="h-16 w-16 mx-auto mb-4 text-gray-600" />
<h2 className="text-xl font-bold mb-2">Log in to create collections</h2>
<p className="text-gray-400 mb-6">
Save your favorite spots to personalized lists like "Barcelona Trip 2024" or "Best Rails in LA"
</p>
</CardContent>
</Card>
)}
{/* Loading state */}
{user && isLoading && (
<div className="grid gap-4 sm:gap-6 grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<CollectionCardSkeleton key={i} />
))}
</div>
)}
{/* Empty state */}
{user && !isLoading && collections?.length === 0 && (
<Card className="border-dashed border-2 border-gray-700">
<CardContent className="py-16 text-center">
<FolderOpen className="h-16 w-16 mx-auto mb-4 text-[#ccff00]/40" />
<h2 className="text-xl font-bold mb-2">No collections yet</h2>
<p className="text-gray-400 mb-6">
Create your first collection to start organizing spots
</p>
<Button
onClick={() => setShowCreateDialog(true)}
className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase gap-2"
>
<Plus className="h-4 w-4" />
Create Your First Collection
</Button>
</CardContent>
</Card>
)}
{/* Collections grid */}
{user && !isLoading && collections && collections.length > 0 && (
<div className="grid gap-4 sm:gap-6 grid-cols-2 lg:grid-cols-3">
{collections.map((collection) => (
<CollectionCard
key={collection.id}
collection={collection}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
{/* Create Collection Dialog */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent className="bg-black border-2 border-[#ccff00]/50 mx-4 max-w-[calc(100vw-2rem)] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white">
<FolderPlus className="h-5 w-5 text-[#ccff00]" />
Create New Collection
</DialogTitle>
<DialogDescription className="text-gray-400 text-sm">
Give your collection a name and optional description
</DialogDescription>
</DialogHeader>
<form onSubmit={handleCreate} className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="create-title" className="text-white text-sm">Collection Name</Label>
<Input
id="create-title"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="Barcelona Trip 2024"
className="bg-black border-gray-700 text-white h-11"
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-description" className="text-white text-sm">Description (optional)</Label>
<Textarea
id="create-description"
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
placeholder="My favorite spots from my Barcelona trip..."
className="bg-black border-gray-700 text-white resize-none"
rows={3}
/>
</div>
<div className="flex gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={() => setShowCreateDialog(false)}
className="flex-1 border-gray-700 h-11"
>
Cancel
</Button>
<Button
type="submit"
disabled={!newTitle.trim() || isCreating}
className="flex-1 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold h-11"
>
{isCreating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<Plus className="h-4 w-4 mr-2" />
<span className="hidden sm:inline">Create Collection</span>
<span className="sm:hidden">Create</span>
</>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
{/* Edit Collection Dialog */}
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
<DialogContent className="bg-black border-2 border-[#ccff00]/50 mx-4 max-w-[calc(100vw-2rem)] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white">
<Pencil className="h-5 w-5 text-[#ccff00]" />
Edit Collection
</DialogTitle>
</DialogHeader>
<form onSubmit={handleEditSubmit} className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="edit-title" className="text-white text-sm">Collection Name</Label>
<Input
id="edit-title"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
className="bg-black border-gray-700 text-white h-11"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-description" className="text-white text-sm">Description (optional)</Label>
<Textarea
id="edit-description"
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
className="bg-black border-gray-700 text-white resize-none"
rows={3}
/>
</div>
<div className="flex gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={() => setShowEditDialog(false)}
className="flex-1 border-gray-700 h-11"
>
Cancel
</Button>
<Button
type="submit"
disabled={!editTitle.trim() || isUpdating}
className="flex-1 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold h-11"
>
{isUpdating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
'Save Changes'
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
{/* 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">Delete Collection?</AlertDialogTitle>
<AlertDialogDescription className="text-gray-400 text-sm">
This will permanently delete "{selectedCollection?.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={handleDeleteConfirm}
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>
);
}