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>
267 lines
10 KiB
TypeScript
267 lines
10 KiB
TypeScript
import { useState } from 'react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
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 { useToast } from '@/hooks/useToast';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import {
|
|
useMySpotCollections,
|
|
useCreateSpotCollection,
|
|
useAddSpotToCollection,
|
|
} from '@/hooks/useSpotCollections';
|
|
import { SKATE_SPOT_KIND, type SkateSpot, type SpotCollection } from '@/lib/types';
|
|
import { FolderPlus, Plus, Check, Loader2, FolderOpen, MapPin } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface AddToCollectionDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
spot: SkateSpot;
|
|
}
|
|
|
|
export function AddToCollectionDialog({ open, onOpenChange, spot }: AddToCollectionDialogProps) {
|
|
const { user } = useCurrentUser();
|
|
const { data: collections, isLoading } = useMySpotCollections();
|
|
const { createCollection, isPending: isCreating } = useCreateSpotCollection();
|
|
const { addSpotToCollection, isPending: isAdding } = useAddSpotToCollection();
|
|
const { toast } = useToast();
|
|
|
|
const [showNewForm, setShowNewForm] = useState(false);
|
|
const [newTitle, setNewTitle] = useState('');
|
|
const [newDescription, setNewDescription] = useState('');
|
|
const [addingToId, setAddingToId] = useState<string | null>(null);
|
|
|
|
const spotRef = `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`;
|
|
|
|
const handleAddToCollection = async (collection: SpotCollection) => {
|
|
if (collection.spotRefs.includes(spotRef)) {
|
|
toast({
|
|
title: 'Already saved',
|
|
description: `"${spot.title}" is already in "${collection.title}"`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
setAddingToId(collection.id);
|
|
try {
|
|
await addSpotToCollection({
|
|
collection,
|
|
spotPubkey: spot.pubkey,
|
|
spotIdentifier: spot.identifier,
|
|
});
|
|
toast({
|
|
title: 'Spot saved!',
|
|
description: `Added "${spot.title}" to "${collection.title}"`,
|
|
});
|
|
onOpenChange(false);
|
|
} catch (error) {
|
|
toast({
|
|
title: 'Failed to save',
|
|
description: error instanceof Error ? error.message : 'Something went wrong',
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setAddingToId(null);
|
|
}
|
|
};
|
|
|
|
const handleCreateCollection = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!newTitle.trim()) return;
|
|
|
|
try {
|
|
await createCollection({
|
|
title: newTitle.trim(),
|
|
description: newDescription.trim() || undefined,
|
|
spotRefs: [spotRef], // Add the current spot to the new collection
|
|
});
|
|
toast({
|
|
title: 'Collection created!',
|
|
description: `"${spot.title}" added to your new collection "${newTitle}"`,
|
|
});
|
|
setNewTitle('');
|
|
setNewDescription('');
|
|
setShowNewForm(false);
|
|
onOpenChange(false);
|
|
} catch (error) {
|
|
toast({
|
|
title: 'Failed to create collection',
|
|
description: error instanceof Error ? error.message : 'Something went wrong',
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
if (!user) {
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="bg-black border-2 border-[#ff0040]/50">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2 text-white">
|
|
<FolderPlus className="h-5 w-5 text-[#ccff00]" />
|
|
Save to Collection
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="py-8 text-center">
|
|
<p className="text-gray-400">Please log in to save spots to collections</p>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="bg-black border-2 border-[#ff0040]/50 mx-4 max-w-[calc(100vw-2rem)] sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2 text-white">
|
|
<FolderPlus className="h-5 w-5 text-[#ccff00]" />
|
|
Save to Collection
|
|
</DialogTitle>
|
|
<DialogDescription className="text-gray-400 text-sm">
|
|
Save "{spot.title}" to one of your collections
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4 mt-4">
|
|
{/* Existing Collections */}
|
|
{isLoading ? (
|
|
<div className="space-y-2">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-16 w-full" />
|
|
))}
|
|
</div>
|
|
) : collections && collections.length > 0 ? (
|
|
<div className="space-y-2 max-h-48 sm:max-h-64 overflow-y-auto pr-1 sm:pr-2 -mr-1 sm:-mr-2">
|
|
{collections.map((collection) => {
|
|
const isInCollection = collection.spotRefs.includes(spotRef);
|
|
const isAddingThis = addingToId === collection.id;
|
|
|
|
return (
|
|
<button
|
|
key={collection.id}
|
|
onClick={() => handleAddToCollection(collection)}
|
|
disabled={isAdding || isInCollection}
|
|
className={cn(
|
|
'w-full p-2.5 sm:p-3 rounded-lg border-2 transition-all text-left flex items-center gap-2.5 sm:gap-3 touch-manipulation active:scale-[0.98]',
|
|
isInCollection
|
|
? 'border-[#ccff00]/50 bg-[#ccff00]/10 cursor-default'
|
|
: 'border-gray-700 hover:border-[#ccff00]/50 hover:bg-[#ccff00]/5'
|
|
)}
|
|
>
|
|
<div className={cn(
|
|
'w-9 h-9 sm:w-10 sm:h-10 rounded-lg flex items-center justify-center flex-shrink-0',
|
|
isInCollection ? 'bg-[#ccff00]/20' : 'bg-gray-800'
|
|
)}>
|
|
{isAddingThis ? (
|
|
<Loader2 className="h-4 w-4 sm:h-5 sm:w-5 animate-spin text-[#ccff00]" />
|
|
) : isInCollection ? (
|
|
<Check className="h-4 w-4 sm:h-5 sm:w-5 text-[#ccff00]" />
|
|
) : (
|
|
<FolderOpen className="h-4 w-4 sm:h-5 sm:w-5 text-gray-400" />
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className={cn(
|
|
'font-bold truncate text-sm sm:text-base',
|
|
isInCollection ? 'text-[#ccff00]' : 'text-white'
|
|
)}>
|
|
{collection.title}
|
|
</p>
|
|
<p className="text-[10px] sm:text-xs text-gray-500 flex items-center gap-1">
|
|
<MapPin className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
|
{collection.spotRefs.length} spot{collection.spotRefs.length !== 1 ? 's' : ''}
|
|
{isInCollection && ' · Already saved'}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
) : !showNewForm ? (
|
|
<div className="text-center py-4 sm:py-6 text-gray-400">
|
|
<FolderOpen className="h-10 w-10 sm:h-12 sm:w-12 mx-auto mb-2 sm:mb-3 opacity-50" />
|
|
<p className="text-sm sm:text-base">You don't have any collections yet</p>
|
|
<p className="text-xs sm:text-sm">Create your first one below!</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Create New Collection Form */}
|
|
{showNewForm ? (
|
|
<form onSubmit={handleCreateCollection} className="space-y-3 sm:space-y-4 pt-3 sm:pt-4 border-t border-gray-800">
|
|
<div className="space-y-1.5 sm:space-y-2">
|
|
<Label htmlFor="title" className="text-white text-sm">Collection Name</Label>
|
|
<Input
|
|
id="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-1.5 sm:space-y-2">
|
|
<Label htmlFor="description" className="text-white text-sm">Description (optional)</Label>
|
|
<Textarea
|
|
id="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={2}
|
|
/>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => setShowNewForm(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" />
|
|
<span className="hidden sm:inline">Creating...</span>
|
|
<span className="sm:hidden">...</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Plus className="h-4 w-4 sm:mr-2" />
|
|
<span className="hidden sm:inline">Create & Save</span>
|
|
<span className="sm:hidden">Create</span>
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
) : (
|
|
<Button
|
|
onClick={() => setShowNewForm(true)}
|
|
variant="outline"
|
|
className="w-full border-dashed border-gray-600 hover:border-[#ccff00]/50 hover:bg-[#ccff00]/5 h-11"
|
|
>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Create New Collection
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|