- AppRouter wraps every route (except /amber-callback and the 404) in ONE
<Route element={<Layout/>}> with an <Outlet/>; the chrome now persists across
navigation instead of being torn down and rebuilt on every page.
- Layout: children optional (falls back to <Outlet/>); the map is full-bleed via
a route check (isFullBleed) instead of a per-page noPadding prop.
- All 59 pages + CraftProfileForm/ErrorState/NewsletterIssueView now render
fragments instead of <Layout> (structure-preserving), imports dropped.
Removes the whole remount class: chrome no longer re-runs its hooks per
navigation, Sasquatch chat state survives navigation, and the header can't
re-open Amber on every page change.
468 lines
18 KiB
TypeScript
468 lines
18 KiB
TypeScript
import { useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { useSeoMeta } from '@unhead/react';
|
|
import { Card, CardContent, CardHeader } 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 (
|
|
<>
|
|
<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>
|
|
</>
|
|
);
|
|
}
|