import { useState, useEffect, useRef, useCallback } 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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useUploadFile } from '@/hooks/useUploadFile'; import { useSkateSpots } from '@/hooks/useSkateSpots'; import { useFormDraft } from '@/hooks/useFormDraft'; 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 { SpotLocationPicker, type LatLng } from '@/components/SpotLocationPicker'; import { SKATE_SPOT_KIND } from '@/lib/types'; import { reverseGeocodeRegions, pushRegionTags, type GeocodedRegions } 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, Check, Camera, ChevronDown, Crosshair, History, } from 'lucide-react'; import { CameraCapture } from '@/components/CameraCapture'; import { Link } from 'react-router-dom'; /** Per-field inline validation messages (no toast for field errors). */ type FieldErrors = { title?: string; description?: string; address?: string; location?: string; type?: string; photo?: string; }; /** Shape of the autosaved draft. Keep every field optional on read so a draft * saved before a new field existed can never crash the form. */ type SpotDraft = { title: string; description: string; location: string; latitude: string; longitude: string; selectedCategory: string; tags: string[]; accessType: 'public' | 'private' | 'locals'; shelter: ShelterType; contactInfo: string; images: string[]; }; const DRAFT_PREFIX = 'skatespots:spot-draft:'; /** How long to wait for a reverse-geocode before publishing without region tags. */ const GEOCODE_BUDGET_MS = 2500; 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([]); const [errors, setErrors] = useState({}); const [showMore, setShowMore] = useState(false); const [showManualCoords, setShowManualCoords] = useState(false); // 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(2000); 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); // Derived coordinates from the (possibly manually typed) strings. const lat = parseFloat(latitude); const lng = parseFloat(longitude); const hasCoords = Number.isFinite(lat) && Number.isFinite(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180; const coords: LatLng | null = hasCoords ? { lat, lng } : null; const clearError = (field: keyof FieldErrors) => setErrors((prev) => (prev[field] ? { ...prev, [field]: undefined } : prev)); // Spots within 60 m of a point — used both for the live warning (shown as soon // as the pin lands) and the final guard on submit. const findNearby = useCallback( (la: number, ln: number) => (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(la, ln, 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), [existingSpots], ); // Warn as soon as the pin lands (debounced), not only when you hit submit. useEffect(() => { if (!hasCoords) { setDupWarning(null); return; } const timer = window.setTimeout(() => { if (dupConfirmedRef.current) return; const near = findNearby(lat, lng); setDupWarning(near.length > 0 ? near : null); }, 400); return () => window.clearTimeout(timer); }, [hasCoords, lat, lng, findNearby]); // Moving the pin re-arms the "publish anyway" confirmation. useEffect(() => { dupConfirmedRef.current = false; }, [lat, lng]); const handlePick = (c: LatLng) => { setLatitude(c.lat.toFixed(6)); setLongitude(c.lng.toFixed(6)); clearError('location'); }; // Autosave the draft so a long form isn't lost on a back-navigation. const draft = useFormDraft( DRAFT_PREFIX, 'new', { title, description, location, latitude, longitude, selectedCategory, tags, accessType, shelter, contactInfo, images }, (d) => { setTitle(d.title ?? ''); setDescription(d.description ?? ''); setLocation(d.location ?? ''); setLatitude(d.latitude ?? ''); setLongitude(d.longitude ?? ''); setSelectedCategory(d.selectedCategory ?? ''); setTags(Array.isArray(d.tags) ? d.tags : []); setAccessType(d.accessType ?? 'public'); setShelter(d.shelter ?? DEFAULT_SHELTER); setContactInfo(d.contactInfo ?? ''); setImages(Array.isArray(d.images) ? d.images : []); }, ); // Pre-fill coordinates from URL params (from map tap / quick capture). useEffect(() => { const latParam = searchParams.get('lat'); const lngParam = searchParams.get('lng'); if (latParam && lngParam) { setLatitude(latParam); setLongitude(lngParam); } // 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); clearError('type'); }; 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); clearError('location'); 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) { clearError('photo'); 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); clearError('photo'); 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(); // Inline validation — collect every missing required field at once instead // of firing one toast at a time. Photo + description are mandatory: they are // the cheapest defence against test pins and empty husks. const nextErrors: FieldErrors = {}; if (!title.trim()) nextErrors.title = t('createSpot.nameRequired'); if (!description.trim()) nextErrors.description = t('createSpot.descriptionRequired'); if (!location.trim()) nextErrors.address = t('createSpot.addressRequired'); if (!hasCoords) nextErrors.location = t('spotUi.locationRequiredDesc'); if (!selectedCategory) nextErrors.type = t('spotUi.spotTypeRequiredDesc'); if (images.length === 0) nextErrors.photo = t('createSpot.photoRequired'); if (Object.keys(nextErrors).length > 0) { setErrors(nextErrors); return; } setErrors({}); // 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; } // Look for an existing spot within 60 m and ask before creating a clone. if (!dupConfirmedRef.current) { const nearby = findNearby(lat, lng); 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. Capped so a slow provider can't make // "Publish" feel stuck — a miss is covered by the stats' read-time // derivation of an untagged pin's coords. const emptyRegions: GeocodedRegions = { city: '', country: '', state: '', countryCode: '' }; const geo = await Promise.race([ reverseGeocodeRegions(lat, lng), new Promise((resolve) => { window.setTimeout(() => resolve(emptyRegions), GEOCODE_BUDGET_MS); }), ]); 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, }); draft.clear(); toast({ title: t('spotUi.spotAdded'), description: 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')}

{/* Unsaved-draft banner */} {draft.resumable && (

{t('createSpot.draftTitle')}

{t('createSpot.draftBody')}

)}
{t('spots.spotDetails')} {t('createSpot.requiredFields')} {/* ── Location (map picker) ─────────────────────────────── */}
{t('createSpot.tapToPlace')}
{errors.location && (

{errors.location}

)}
{ setLatitude(e.target.value); clearError('location'); }} inputMode="decimal" /> { setLongitude(e.target.value); clearError('location'); }} inputMode="decimal" />
{/* Address (required) — the human-readable place name. */}
{ setLocation(e.target.value); clearError('address'); }} className={cn(errors.address && 'border-red-500/60')} /> {errors.address &&

{errors.address}

}
{/* Duplicate warning — appears as soon as the pin lands, right below the map, instead of only on submit. */} {dupWarning && (

{t('spotUi.spotMayAlreadyExist')}

{dupWarning.length === 1 ? t('spotUi.duplicateOne') : t('spotUi.duplicateOther')}

    {dupWarning.map(({ spot, km }) => (
  • {spot.title || t('spotUi.untitledSpot')} · {t('spotUi.metresAway', { distance: Math.round(km * 1000) })}
  • ))}
)} {/* ── Title ─────────────────────────────────────────────── */}
{ setTitle(e.target.value); clearError('title'); }} className={cn(errors.title && 'border-red-500/60')} /> {errors.title &&

{errors.title}

}
{/* ── Description ───────────────────────────────────────── */}