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: `${t('createSpot.title')} - SkateSpots`, description: t('createSpot.subtitle'), }); 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: t('spotUi.photoUploadFailed'), description: error instanceof Error ? error.message : t('spotUi.couldNotUploadPhoto'), 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: 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. 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 ? 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', }); } }; // 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: t('spotUi.photoCaptured'), description: t('spotUi.photoCapturedDesc'), }); } } catch (error) { toast({ title: t('spotUi.uploadFailed'), description: error instanceof Error ? error.message : t('spotUi.failedToUploadPhoto'), 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: 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; } // 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: t('spotUi.hangTight'), description: t('spotUi.hangTightDesc'), }); 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: t('spotUi.addPhotoOrDescription'), description: t('spotUi.addPhotoOrDescriptionDesc'), 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: t('spotUi.spotMayAlreadyBeOnMap'), description: t('spotUi.checkPinBelow'), }); 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', t('spotUi.altSkateSpot', { title: title.trim() })]); await createEvent({ kind: SKATE_SPOT_KIND, content: description.trim(), tags: eventTags, }); toast({ title: t('spotUi.spotAdded'), description: images.length === 0 ? t('spotUi.spotAddedNoPhoto') : t('spotUi.spotAddedDesc'), }); // Navigate to spot detail using pubkey and identifier navigate(`/spot/${user?.pubkey}/${identifier}`); } catch (error) { console.error('Failed to create spot:', error); toast({ title: t('spotUi.failedToPublish'), description: error instanceof Error ? error.message : t('spotUi.failedToPublishDesc'), 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 */}