import { useState, useEffect } from 'react'; import { useSeoMeta } from '@unhead/react'; import { useNavigate, useParams } from 'react-router-dom'; import ngeohash from 'ngeohash'; 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 { useTranslation } from '@/contexts/language'; import { LoginArea } from '@/components/auth/LoginArea'; import { PhotoDropzone } from '@/components/PhotoDropzone'; import { TagInput } from '@/components/TagInput'; import { SKATE_SPOT_KIND } from '@/lib/types'; import { reverseGeocodeRegions, pushRegionTags } from '@/lib/geocodeRegions'; import { spotCategories, MAX_TAGS, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } from '@/lib/skateTags'; import { SPOT_TAG_CATEGORY_KEYS, SPOT_TAG_CATEGORY_DESC_KEYS, SHELTER_KEYS, SHELTER_DESC_KEYS } from '@/lib/labelKeys'; import { cn } from '@/lib/utils'; import { MapPin, X, Loader2, ArrowLeft, AlertTriangle, Check, } from 'lucide-react'; import { Link } from 'react-router-dom'; import NotFound from './NotFound'; function EditSpotSkeleton() { return ( <>
); } export default function EditSpotPage() { const { t } = useTranslation(); 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(''); const [tags, setTags] = useState([]); const [accessType, setAccessType] = useState<'public' | 'private' | 'locals'>('public'); const [shelter, setShelter] = useState(DEFAULT_SHELTER); const [contactInfo, setContactInfo] = useState(''); const [images, setImages] = useState([]); const [isGettingLocation, setIsGettingLocation] = useState(false); const [initialized, setInitialized] = useState(false); useSeoMeta({ title: spot ? `${t('spots.editSpot')} ${spot.title} - SkateSpots` : `${t('spots.editSpot')} - SkateSpots`, description: t('spotUi.editSpotSeoDesc'), }); // Initialize form with existing data useEffect(() => { if (spot && !initialized) { setTitle(spot.title); setDescription(spot.description || ''); setLocation(spot.location || ''); setAccessType(spot.accessType || 'public'); setShelter(spot.shelter || DEFAULT_SHELTER); 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: t('spotUi.geolocationNotSupported'), description: t('spotUi.geolocationNotSupportedDesc'), 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: t('spotUi.locationFound'), description: t('spotUi.locationFoundDesc'), }); }, (error) => { setIsGettingLocation(false); toast({ title: t('spotUi.locationError'), description: error.message, variant: 'destructive', }); } ); }; // Upload one or more selected/dropped photos, in sequence. const uploadImageFiles = async (files: File[]) => { if (files.length === 0) return; let added = 0; try { for (const file of files) { const tags = await uploadFile(file); const url = tags[0]?.[1]; if (url) { setImages((prev) => (prev.includes(url) ? prev : [...prev, url])); added += 1; } } if (added > 0) { toast({ title: added > 1 ? t('spotUi.photosUploaded', { count: added }) : t('spotUi.imageUploaded'), description: added > 1 ? t('spotUi.photosAdded') : t('spotUi.imageUploadedDesc'), }); } } catch (error) { toast({ title: t('spotUi.uploadFailed'), description: error instanceof Error ? error.message : t('spotUi.failedToUploadImage'), variant: 'destructive', }); } }; 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: t('spotUi.titleRequired'), description: t('spotUi.titleRequiredDesc'), variant: 'destructive', }); return; } if (!latitude || !longitude) { toast({ title: t('spotUi.locationRequired'), description: t('spotUi.locationRequiredDesc'), 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: t('spotUi.invalidCoordinates'), description: t('spotUi.invalidCoordinatesDesc'), variant: 'destructive', }); return; } if (!selectedCategory) { toast({ title: t('spotUi.spotTypeRequired'), description: t('spotUi.spotTypeRequiredDesc'), variant: 'destructive', }); return; } try { // Generate geohash from coordinates (7 chars = ~153m precision) const geohash = ngeohash.encode(lat, lng, 7); // Best-effort reverse geocode → city/country/country_code. Re-run on edit // so moving a spot updates its region, AND so a legacy spot that has no // tags gets them the next time it's saved. (This page rebuilds tags from // scratch, so without this it silently DROPPED any existing region tags.) const geo = await reverseGeocodeRegions(lat, lng); // 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', t('spotUi.altSkateSpot', { title: title.trim() })], ]; pushRegionTags(eventTags, geo); if (location.trim()) { eventTags.push(['location', location.trim()]); } if (contactInfo.trim()) { eventTags.push(['contact', contactInfo.trim()]); } // Shelter / weather-protection level (see CreateSpotPage for rationale). eventTags.push(['shelter', shelter]); if (SHELTERED_VALUES.includes(shelter)) { eventTags.push(['t', shelter]); } // 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: t('spotUi.spotUpdated'), description: t('spotUi.spotUpdatedDesc'), }); navigate(`/spot/${user?.pubkey}/${spot.identifier}`); } catch (error) { toast({ title: t('spotUi.failedToUpdate'), description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'), variant: 'destructive', }); } }; if (isLoading) { return ; } if (error || !spot) { return ; } // Check if user is the owner if (!user) { return ( <>

{t('spotUi.loginRequiredTitle')}

{t('spotUi.loginToEditSpot')}

); } if (user.pubkey !== spot.pubkey) { return ( <>

{t('spotUi.accessDeniedTitle')}

{t('spotUi.accessDeniedDesc')}

); } return ( <>
{t('spotUi.backToSpot')}

{t('spots.editSpot')}

{t('spotUi.updateDetailsFor', { title: spot.title })}

{t('spots.spotDetails')} {t('spotUi.updateSpotDesc')} {/* Title */}
setTitle(e.target.value)} required />
{/* Description */}