import { useState, useEffect, useRef } from 'react'; import { useSeoMeta } from '@unhead/react'; import { useNavigate, useSearchParams } 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 { useNostrPublish } from '@/hooks/useNostrPublish'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useUploadFile } from '@/hooks/useUploadFile'; import { useSkateSpots } from '@/hooks/useSkateSpots'; import { calculateDistance } from '@/lib/sortOptions'; import type { SkateSpot } from '@/lib/types'; import { useToast } from '@/hooks/useToast'; import { useTranslation } from '@/contexts/language'; import { LoginArea } from '@/components/auth/LoginArea'; import { QualityMeter } from '@/components/QualityMeter'; 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 { cn } from '@/lib/utils'; import { MapPin, X, Loader2, ArrowLeft, Check, Camera, } from 'lucide-react'; import { CameraCapture } from '@/components/CameraCapture'; import { Link } from 'react-router-dom'; export default function CreateSpotPage() { const { t } = useTranslation(); useSeoMeta({ title: 'Add a Skate Spot - SkateSpots', description: 'Share a new skateboarding spot with the community.', }); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { user } = useCurrentUser(); 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([]); // Duplicate guard: warn when a spot already exists right where you're pinning // one. This is the cheapest fix for clone spam (five near-identical pins for // the same spot) — and it points the contributor at the existing pin instead. const { data: existingSpots } = useSkateSpots(500); const [dupWarning, setDupWarning] = useState | null>(null); const dupConfirmedRef = useRef(false); const [isGettingLocation, setIsGettingLocation] = useState(false); const [showCamera, setShowCamera] = useState(false); // True while the Quick Snap photo is uploading in the background after landing // on this form. const [isUploadingQuickPhoto, setIsUploadingQuickPhoto] = useState(false); // Pre-fill coordinates from URL params (from map click) useEffect(() => { const lat = searchParams.get('lat'); const lng = searchParams.get('lng'); if (lat && lng) { setLatitude(lat); setLongitude(lng); } // Check for quick capture image from map page const quickCapture = searchParams.get('quickCapture'); if (quickCapture === 'true') { // Backward-compat: an already-uploaded URL (old flow). const capturedImage = sessionStorage.getItem('quickCaptureImage'); if (capturedImage) { setImages([capturedImage]); sessionStorage.removeItem('quickCaptureImage'); } // New Quick Snap flow: a raw photo (data URL) stashed by the map page. // We navigate here immediately for speed, then upload the photo HERE so a // slow/failed Blossom upload never strands the user before the form. const pendingPhoto = sessionStorage.getItem('quickCapturePendingPhoto'); if (pendingPhoto) { sessionStorage.removeItem('quickCapturePendingPhoto'); (async () => { setIsUploadingQuickPhoto(true); try { const res = await fetch(pendingPhoto); const blob = await res.blob(); const file = new File([blob], `skate-spot-${Date.now()}.jpg`, { type: 'image/jpeg' }); const uploadTags = await uploadFile(file); const url = uploadTags[0]?.[1]; if (url) { setImages((prev) => (prev.includes(url) ? prev : [...prev, url])); } } catch (error) { toast({ title: 'Photo upload failed', description: error instanceof Error ? error.message : 'Could not upload the photo. You can add one below.', variant: 'destructive', }); } finally { setIsUploadingQuickPhoto(false); } })(); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchParams]); 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', }); } ); }; // Upload one or more selected/dropped photos. Uploads run in sequence so we // don't hammer Blossom with parallel requests. 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 ? `${added} photos uploaded` : 'Image uploaded', description: added > 1 ? 'Your photos have been added.' : 'Your image has been uploaded successfully.', }); } } catch (error) { toast({ title: 'Upload failed', description: error instanceof Error ? error.message : 'Failed to upload image', variant: 'destructive', }); } }; // Handle camera capture const handleCameraCapture = async (file: File) => { try { const tags = await uploadFile(file); const url = tags[0]?.[1]; if (url) { setImages((prev) => [...prev, url]); setShowCamera(false); toast({ title: 'Photo captured!', description: 'Your photo has been uploaded successfully.', }); } } catch (error) { toast({ title: 'Upload failed', description: error instanceof Error ? error.message : 'Failed to upload photo', variant: 'destructive', }); } }; const removeImage = (url: string) => { setImages((prev) => prev.filter((img) => img !== url)); }; const handleSubmit = async (e?: React.FormEvent) => { e?.preventDefault(); 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; } if (!selectedCategory) { toast({ title: 'Spot type required', description: 'Please pick a spot type so skaters know what they’re rolling up to.', variant: 'destructive', }); return; } // Don't publish while the Quick Snap photo is still uploading — otherwise the // spot would go out without the photo the user just took. if (isUploadingQuickPhoto) { toast({ title: 'Hang tight', description: 'Your photo is still uploading. It’ll be attached in a second.', }); return; } // A photo is still NOT required — but a spot needs *something* beyond a // title + type. Require a photo OR a description so the empty husk (a "test" // pin, an airport with one word) can't publish, without blocking an honest // mapper who has no picture on hand but can describe the place. if (images.length === 0 && !description.trim()) { toast({ title: 'Add a photo or a description', description: 'Give skaters something to go on — a picture, or a line about the place.', variant: 'destructive', }); return; } // Look for an existing spot within 60 m and ask before creating a clone. if (!dupConfirmedRef.current) { const nearby = (existingSpots ?? []) .map((s) => { if (!s.geohash) return null; const { latitude, longitude } = ngeohash.decode(s.geohash); if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null; return { spot: s, km: calculateDistance(lat, lng, latitude, longitude) }; }) .filter((x): x is { spot: SkateSpot; km: number } => !!x && x.km < 0.06) .sort((a, b) => a.km - b.km) .slice(0, 3); if (nearby.length > 0) { setDupWarning(nearby); toast({ title: 'That spot may already be on the map', description: 'Check the pin below before adding a duplicate.', }); return; } } try { // Generate geohash from coordinates (7 chars = ~153m precision) const geohash = ngeohash.encode(lat, lng, 7); // Create unique identifier for addressable event const identifier = title .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '') .slice(0, 50) + '-' + Date.now().toString(36); // Reverse geocode to get structured city/country tags (best-effort; the // helper never throws). These power Sasquatch's city/country search and // the /stats region breakdown. const geo = await reverseGeocodeRegions(lat, lng); let locationStr = location.trim(); if (!locationStr && geo.city) { // Auto-populate an empty address from the geocode. locationStr = [geo.city, geo.state, geo.country].filter(Boolean).join(', '); } // Build event tags const eventTags: string[][] = [ ['d', identifier], // Required for addressable events ['title', title.trim()], ['g', geohash], ['t', 'skatespot'], // Primary identifier for spot events ['access', accessType], ['lat', lat.toString()], ['lon', lng.toString()], ]; if (locationStr) { eventTags.push(['location', locationStr]); } // Structured city/country/country_code tags — critical for Sasquatch relay // search and the /stats region breakdown (country_code is the stable key). pushRegionTags(eventTags, geo); if (contactInfo.trim()) { eventTags.push(['contact', contactInfo.trim()]); } // Shelter / weather-protection level. Always record the explicit value so // the spot detail + map can show it; additionally add a queryable `t` tag // for sheltered spots (indoor/covered/partial) so "dry spots" filtering and // Sasquatch search work at the relay level. Outdoor is the implicit default // and adds no `t` tag (keeps the common case from tagging everything). 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]); } // Add images for (const imageUrl of images) { // Use imeta tag for NIP-92 compatibility eventTags.push(['imeta', `url ${imageUrl}`, 'm image/jpeg']); } // Add alt tag for NIP-31 (human-readable description) eventTags.push(['alt', `Skate spot: ${title.trim()}`]); await createEvent({ kind: SKATE_SPOT_KIND, content: description.trim(), tags: eventTags, }); toast({ title: 'Spot added!', description: images.length === 0 ? 'Published — and marked Unverified until someone adds a photo. A picture of the spot goes a long way.' : 'Your skate spot has been published to the network.', }); // Navigate to spot detail using pubkey and identifier navigate(`/spot/${user?.pubkey}/${identifier}`); } catch (error) { console.error('Failed to create spot:', error); toast({ title: 'Failed to publish', description: error instanceof Error ? error.message : 'Something went wrong. Check console for details.', variant: 'destructive', }); } }; if (!user) { return ( <>

{t('errors.loginRequired')}

{t('createSpot.loginToAdd')}

); } return ( <>
{t('createSpot.backToMap')}

{t('createSpot.title')}

{t('createSpot.subtitle')}

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