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

641 lines
23 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate, useParams } from 'react-router-dom';
import ngeohash from 'ngeohash';
import { Layout } from '@/components/Layout';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast';
import { useSkateSpot } from '@/hooks/useSkateSpots';
import { LoginArea } from '@/components/auth/LoginArea';
import { TagInput } from '@/components/TagInput';
import { SKATE_SPOT_KIND } from '@/lib/types';
import { spotCategories, MAX_TAGS } from '@/lib/skateTags';
import { cn } from '@/lib/utils';
import {
MapPin,
Upload,
X,
Loader2,
ArrowLeft,
AlertTriangle,
Check,
} from 'lucide-react';
import { Link } from 'react-router-dom';
import NotFound from './NotFound';
function EditSpotSkeleton() {
return (
<Layout>
<div className="container px-4 py-8 max-w-2xl">
<Skeleton className="h-6 w-32 mb-4" />
<Skeleton className="h-10 w-64 mb-2" />
<Skeleton className="h-5 w-48 mb-6" />
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent className="space-y-6">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-10 w-full" />
<div className="grid grid-cols-2 gap-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
<Skeleton className="h-10 w-full" />
</CardContent>
</Card>
</div>
</Layout>
);
}
export default function EditSpotPage() {
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
const navigate = useNavigate();
const { user } = useCurrentUser();
const { data: spot, isLoading, error } = useSkateSpot(identifier || '', pubkey);
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
// Form state
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [location, setLocation] = useState('');
const [latitude, setLatitude] = useState('');
const [longitude, setLongitude] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('');
const [tags, setTags] = useState<string[]>([]);
const [accessType, setAccessType] = useState<'public' | 'private' | 'locals'>('public');
const [contactInfo, setContactInfo] = useState('');
const [images, setImages] = useState<string[]>([]);
const [isGettingLocation, setIsGettingLocation] = useState(false);
const [initialized, setInitialized] = useState(false);
useSeoMeta({
title: spot ? `Edit ${spot.title} - SkateSpots` : 'Edit Spot - SkateSpots',
description: 'Edit your skateboarding spot.',
});
// Initialize form with existing data
useEffect(() => {
if (spot && !initialized) {
setTitle(spot.title);
setDescription(spot.description || '');
setLocation(spot.location || '');
setAccessType(spot.accessType || 'public');
setContactInfo(spot.contactInfo || '');
setImages(spot.images);
// Set category (new format) or extract from types (legacy)
setSelectedCategory(spot.category || '');
// Set tags (new format)
setTags(spot.tags || []);
// Decode geohash to get coordinates
if (spot.geohash) {
const { latitude: lat, longitude: lng } = ngeohash.decode(spot.geohash);
setLatitude(lat.toFixed(6));
setLongitude(lng.toFixed(6));
}
setInitialized(true);
}
}, [spot, initialized]);
const handleCategoryChange = (category: string) => {
setSelectedCategory(category);
};
const getCurrentLocation = () => {
if (!navigator.geolocation) {
toast({
title: 'Geolocation not supported',
description: 'Your browser does not support geolocation.',
variant: 'destructive',
});
return;
}
setIsGettingLocation(true);
navigator.geolocation.getCurrentPosition(
(position) => {
setLatitude(position.coords.latitude.toFixed(6));
setLongitude(position.coords.longitude.toFixed(6));
setIsGettingLocation(false);
toast({
title: 'Location found',
description: 'Your current location has been set.',
});
},
(error) => {
setIsGettingLocation(false);
toast({
title: 'Location error',
description: error.message,
variant: 'destructive',
});
}
);
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
try {
const file = files[0];
const tags = await uploadFile(file);
const url = tags[0]?.[1];
if (url) {
setImages((prev) => [...prev, url]);
toast({
title: 'Image uploaded',
description: 'Your image has been uploaded successfully.',
});
}
} catch (error) {
toast({
title: 'Upload failed',
description: error instanceof Error ? error.message : 'Failed to upload image',
variant: 'destructive',
});
}
e.target.value = '';
};
const removeImage = (url: string) => {
setImages((prev) => prev.filter((img) => img !== url));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!spot) return;
if (!title.trim()) {
toast({
title: 'Title required',
description: 'Please enter a name for this spot.',
variant: 'destructive',
});
return;
}
if (!latitude || !longitude) {
toast({
title: 'Location required',
description: 'Please enter coordinates or use your current location.',
variant: 'destructive',
});
return;
}
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
toast({
title: 'Invalid coordinates',
description: 'Please enter valid latitude (-90 to 90) and longitude (-180 to 180).',
variant: 'destructive',
});
return;
}
try {
// Generate geohash from coordinates (7 chars = ~153m precision)
const geohash = ngeohash.encode(lat, lng, 7);
// Build event tags - use the same identifier to update the addressable event
const eventTags: string[][] = [
['d', spot.identifier],
['title', title.trim()],
['g', geohash],
['t', 'skatespot'],
['access', accessType],
['lat', lat.toString()],
['lon', lng.toString()],
['alt', `Skate spot: ${title.trim()}`],
];
if (location.trim()) {
eventTags.push(['location', location.trim()]);
}
if (contactInfo.trim()) {
eventTags.push(['contact', contactInfo.trim()]);
}
// Add category as dedicated tag (new format)
if (selectedCategory) {
eventTags.push(['category', selectedCategory]);
// Also add as t tag for backwards compatibility and relay filtering
eventTags.push(['t', selectedCategory]);
}
// Add all freeform tags
for (const tag of tags) {
eventTags.push(['t', tag]);
}
for (const imageUrl of images) {
// Use imeta tag for NIP-92 compatibility
eventTags.push(['imeta', `url ${imageUrl}`, 'm image/jpeg']);
}
await createEvent({
kind: SKATE_SPOT_KIND,
content: description.trim(),
tags: eventTags,
});
toast({
title: 'Spot updated!',
description: 'Your changes have been published to the network.',
});
navigate(`/spot/${user?.pubkey}/${spot.identifier}`);
} catch (error) {
toast({
title: 'Failed to update',
description: error instanceof Error ? error.message : 'Something went wrong',
variant: 'destructive',
});
}
};
if (isLoading) {
return <EditSpotSkeleton />;
}
if (error || !spot) {
return <NotFound />;
}
// Check if user is the owner
if (!user) {
return (
<Layout>
<div className="container px-4 py-8">
<Card className="max-w-xl mx-auto border-dashed">
<CardContent className="py-12 text-center">
<MapPin className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
<h2 className="text-xl font-bold mb-2">Login Required</h2>
<p className="text-muted-foreground mb-6">
You need to log in to edit this spot.
</p>
<LoginArea className="justify-center" />
</CardContent>
</Card>
</div>
</Layout>
);
}
if (user.pubkey !== spot.pubkey) {
return (
<Layout>
<div className="container px-4 py-8">
<Card className="max-w-xl mx-auto border-destructive">
<CardContent className="py-12 text-center">
<AlertTriangle className="h-12 w-12 mx-auto mb-4 text-destructive" />
<h2 className="text-xl font-bold mb-2">Access Denied</h2>
<p className="text-muted-foreground mb-6">
You can only edit spots that you created.
</p>
<Link to={`/spot/${spot.pubkey}/${spot.identifier}`}>
<Button variant="outline">Back to Spot</Button>
</Link>
</CardContent>
</Card>
</div>
</Layout>
);
}
return (
<Layout>
<div className="container px-4 py-8 max-w-2xl">
<div className="mb-6">
<Link
to={`/spot/${spot.pubkey}/${spot.identifier}`}
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Spot
</Link>
<h1 className="text-3xl font-bold">Edit Spot</h1>
<p className="text-muted-foreground mt-1">
Update the details for {spot.title}
</p>
</div>
<form onSubmit={handleSubmit}>
<Card>
<CardHeader>
<CardTitle>Spot Details</CardTitle>
<CardDescription>
Update the information about this spot. All fields marked with * are required.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Title */}
<div className="space-y-2">
<Label htmlFor="title">Spot Name *</Label>
<Input
id="title"
placeholder="e.g., Hollywood High School"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Describe the spot, obstacles, best times to skate, security info, etc."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
/>
</div>
{/* Location Address */}
<div className="space-y-2">
<Label htmlFor="location">Address / Location</Label>
<Input
id="location"
placeholder="e.g., 1521 N Highland Ave, Los Angeles, CA"
value={location}
onChange={(e) => setLocation(e.target.value)}
/>
</div>
{/* Coordinates */}
<div className="space-y-2">
<Label>Coordinates *</Label>
<div className="grid grid-cols-2 gap-4">
<div>
<Input
placeholder="Latitude (e.g., 34.1012)"
value={latitude}
onChange={(e) => setLatitude(e.target.value)}
required
/>
</div>
<div>
<Input
placeholder="Longitude (e.g., -118.3389)"
value={longitude}
onChange={(e) => setLongitude(e.target.value)}
required
/>
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={getCurrentLocation}
disabled={isGettingLocation}
className="mt-2"
>
{isGettingLocation ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Getting location...
</>
) : (
<>
<MapPin className="h-4 w-4 mr-2" />
Use my current location
</>
)}
</Button>
</div>
{/* Access Type */}
<div className="space-y-3">
<Label className="text-base font-bold">Access Type *</Label>
<div className="space-y-2">
{/* Public */}
<Card
className={cn(
"cursor-pointer transition-all border-2",
accessType === 'public'
? "bg-[#ff0040]/10 border-[#ff0040] ring-2 ring-[#ff0040]/50"
: "border-gray-700 hover:border-[#ff0040]/50"
)}
onClick={() => setAccessType('public')}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="text-2xl">🌍</div>
<div className="flex-1">
<h4 className="font-bold text-white mb-1">Public - Open to Everyone</h4>
<p className="text-sm text-gray-400">Skate parks, public plazas, street spots</p>
</div>
{accessType === 'public' && (
<div className="w-6 h-6 rounded-full bg-[#ff0040] flex items-center justify-center">
<span className="text-white text-xs">✓</span>
</div>
)}
</div>
</CardContent>
</Card>
{/* Private */}
<Card
className={cn(
"cursor-pointer transition-all border-2",
accessType === 'private'
? "bg-[#ff6600]/10 border-[#ff6600] ring-2 ring-[#ff6600]/50"
: "border-gray-700 hover:border-[#ff6600]/50"
)}
onClick={() => setAccessType('private')}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="text-2xl">🔒</div>
<div className="flex-1">
<h4 className="font-bold text-white mb-1">Private Property - Permission Needed</h4>
<p className="text-sm text-gray-400">Backyard ramps, private land, gated spots</p>
</div>
{accessType === 'private' && (
<div className="w-6 h-6 rounded-full bg-[#ff6600] flex items-center justify-center">
<span className="text-white text-xs">✓</span>
</div>
)}
</div>
</CardContent>
</Card>
{/* Ask Locals */}
<Card
className={cn(
"cursor-pointer transition-all border-2",
accessType === 'locals'
? "bg-[#ccff00]/10 border-[#ccff00] ring-2 ring-[#ccff00]/50"
: "border-gray-700 hover:border-[#ccff00]/50"
)}
onClick={() => setAccessType('locals')}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="text-2xl">👥</div>
<div className="flex-1">
<h4 className="font-bold text-white mb-1">Ask Locals First</h4>
<p className="text-sm text-gray-400">Respect the spot, don't blow it out</p>
</div>
{accessType === 'locals' && (
<div className="w-6 h-6 rounded-full bg-[#ccff00] flex items-center justify-center">
<span className="text-black text-xs">✓</span>
</div>
)}
</div>
</CardContent>
</Card>
</div>
</div>
{/* Contact Info for Private/Locals */}
{(accessType === 'private' || accessType === 'locals') && (
<div className="space-y-2">
<Label htmlFor="contact">Contact Information</Label>
<Input
id="contact"
placeholder="e.g., Phone number, Instagram, email..."
value={contactInfo}
onChange={(e) => setContactInfo(e.target.value)}
className="bg-black border-[#ff6600]/30"
/>
<p className="text-xs text-gray-400">
{accessType === 'private'
? 'How should people contact you for permission?'
: 'How should people reach out to ask about the spot?'}
</p>
</div>
)}
{/* Spot Category (required, mutually exclusive) */}
<div className="space-y-3">
<Label className="text-base font-bold">Spot Type *</Label>
<div className="grid grid-cols-2 gap-2">
{spotCategories.map((category) => (
<Card
key={category.value}
className={cn(
"cursor-pointer transition-all border-2 touch-manipulation active:scale-[0.98]",
selectedCategory === category.value
? "bg-[#ff0040]/10 border-[#ff0040] ring-2 ring-[#ff0040]/50"
: "border-gray-700 hover:border-[#ff0040]/50"
)}
onClick={() => handleCategoryChange(category.value)}
>
<CardContent className="p-3 sm:p-4 text-center relative">
<div className="font-bold text-sm sm:text-base">{category.label}</div>
<div className="text-xs text-gray-400 mt-1">{category.description}</div>
{selectedCategory === category.value && (
<div className="absolute top-1 right-1 w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-[#ff0040] flex items-center justify-center">
<Check className="h-2.5 w-2.5 sm:h-3 sm:w-3 text-white" />
</div>
)}
</CardContent>
</Card>
))}
</div>
</div>
{/* Freeform Tags */}
<div className="space-y-3">
<Label className="text-base font-bold">Tags</Label>
<p className="text-xs text-gray-400 -mt-1">
Add tags for features, surfaces, vibes (max {MAX_TAGS})
</p>
<TagInput
tags={tags}
onChange={setTags}
placeholder="Type to search or add tags..."
maxTags={MAX_TAGS}
/>
</div>
{/* Images */}
<div className="space-y-2">
<Label>Photos</Label>
<div className="grid grid-cols-3 gap-4">
{images.map((url) => (
<div key={url} className="relative aspect-video rounded-lg overflow-hidden bg-muted">
<img src={url} alt="Spot" className="w-full h-full object-cover" />
<button
type="button"
onClick={() => removeImage(url)}
className="absolute top-1 right-1 p-1 rounded-full bg-background/80 hover:bg-destructive hover:text-destructive-foreground transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
))}
<label className="aspect-video rounded-lg border-2 border-dashed flex flex-col items-center justify-center cursor-pointer hover:bg-muted/50 transition-colors">
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
disabled={isUploading}
/>
{isUploading ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
) : (
<>
<Upload className="h-6 w-6 text-muted-foreground mb-1" />
<span className="text-xs text-muted-foreground">Upload</span>
</>
)}
</label>
</div>
</div>
{/* Submit */}
<Button
type="submit"
className="w-full"
disabled={isPublishing || isUploading}
>
{isPublishing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<MapPin className="h-4 w-4 mr-2" />
Save Changes
</>
)}
</Button>
</CardContent>
</Card>
</form>
</div>
</Layout>
);
}