import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useSeoMeta } from '@unhead/react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import ngeohash from 'ngeohash'; import { useQueryClient } from '@tanstack/react-query'; 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 { ToastAction } from '@/components/ui/toast'; 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 { 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, SHELTER_KEYS, SHELTER_DESC_KEYS, ACCESS_TYPE_KEYS } from '@/lib/labelKeys'; import { getCurrentPosition } from '@/lib/geolocation'; import { getLastLocation, getLastMapView, saveLastMapView } from '@/lib/lastLocation'; import { PHOTO_CONTRIBUTION_KIND } from '@/hooks/useSpotContributions'; 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; 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; function mimeForImageUrl(url: string): string { const match = url.match(/\.(png|webp|gif|heic|heif|jpe?g)(?:[?#]|$)/i); if (!match) return 'image/jpeg'; const ext = match[1].toLowerCase(); if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg'; if (ext === 'heic' || ext === 'heif') return 'image/heic'; return `image/${ext}`; } function validCoords(latParam: string | null, lngParam: string | null): LatLng | null { if (!latParam || !lngParam) return null; const lat = parseFloat(latParam); const lng = parseFloat(lngParam); if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null; if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null; return { lat, lng }; } export default function CreateSpotPage() { const { t } = useTranslation(); useSeoMeta({ title: `${t('createSpot.title')} - SkateSpots`, description: t('createSpot.subtitle'), }); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const queryClient = useQueryClient(); const { user } = useCurrentUser(); const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const { toast } = useToast(); // Start from the place the user was already looking at. A live GPS fix is // deliberately NOT used as an automatic pin: the pin becomes public. const initial = useMemo(() => { const urlPin = validCoords(searchParams.get('lat'), searchParams.get('lng')); if (urlPin) return { pin: urlPin, source: 'map' as const }; const savedView = getLastMapView(); if (savedView) { return { pin: { lat: savedView.lat, lng: savedView.lon }, source: 'map' as const }; } return { pin: null, source: null }; }, [searchParams]); // Form state const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); const [location, setLocation] = useState(''); const [latitude, setLatitude] = useState(initial.pin ? initial.pin.lat.toFixed(6) : ''); const [longitude, setLongitude] = useState(initial.pin ? initial.pin.lng.toFixed(6) : ''); 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 [showLocationMap, setShowLocationMap] = useState(!initial.pin); 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 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); // 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: spotLat, longitude: spotLng } = ngeohash.decode(s.geohash); if (!Number.isFinite(spotLat) || !Number.isFinite(spotLng)) return null; return { spot: s, km: calculateDistance(la, ln, spotLat, spotLng) }; }) .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'); }; // Pre-fill coordinates from URL params (from map tap / quick capture). useEffect(() => { const pin = validCoords(searchParams.get('lat'), searchParams.get('lng')); if (pin) { setLatitude(pin.lat.toFixed(6)); setLongitude(pin.lng.toFixed(6)); setShowLocationMap(false); clearError('location'); } }, [searchParams]); // Autosave the draft so a long form isn't lost on a back-navigation. Scope it // to the account so switching accounts on a shared device can't reveal one // person's unfinished spot to another. const draft = useFormDraft( DRAFT_PREFIX, user?.pubkey ?? 'anon', { 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 : []); if (d.latitude && d.longitude) setShowLocationMap(false); }, ); const handleCategoryChange = (category: string) => { setSelectedCategory(category); clearError('type'); }; const getCurrentLocation = async () => { setIsGettingLocation(true); try { const position = await getCurrentPosition(); setLatitude(position.lat.toFixed(6)); setLongitude(position.lng.toFixed(6)); setShowLocationMap(false); clearError('location'); toast({ title: t('spotUi.locationFound'), description: t('spotUi.locationFoundDesc'), }); } catch (error) { toast({ title: t('spotUi.locationError'), description: error instanceof Error ? error.message : t('spotUi.locationRequiredDesc'), variant: 'destructive', }); } finally { setIsGettingLocation(false); } }; // 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 photoTags = await uploadFile(file); const url = photoTags[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 photoTags = await uploadFile(file); const url = photoTags[0]?.[1]; if (url) { setImages((prev) => (prev.includes(url) ? 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)); }; // If the pin is already a known spot, a photo + note contribution is much // lower friction than creating a duplicate. This uses the same kind as the // spot detail page's Add Photo flow. const handleContribute = async (target: SkateSpot) => { if (images.length === 0) { toast({ title: t('createSpot.photoRequired'), description: t('spotUi.uploadPhotoFirst'), variant: 'destructive', }); return; } try { const spotRef = `${SKATE_SPOT_KIND}:${target.pubkey}:${target.identifier}`; for (const imageUrl of images) { await createEvent({ kind: PHOTO_CONTRIBUTION_KIND, content: description.trim(), tags: [ ['url', imageUrl], ['a', spotRef], ['m', mimeForImageUrl(imageUrl)], ['alt', t('spotUi.altSkateSpot', { title: target.title })], ], }); } draft.clear(); queryClient.invalidateQueries({ queryKey: ['spot-photos', target.pubkey, target.identifier] }); toast({ title: t('spotUi.photoContributed'), description: t('spotUi.photoContributedDesc'), }); navigate(`/spot/${target.pubkey}/${target.identifier}`); } catch (error) { console.error('Failed to contribute to spot:', error); toast({ title: t('spotUi.failedToPublish'), description: error instanceof Error ? error.message : t('spotUi.failedToPublishDesc'), variant: 'destructive', }); } }; const scrollToFirstError = (errs: FieldErrors) => { const id = errs.location ? 'spot-location' : errs.photo ? 'spot-photos' : errs.title ? 'spot-title' : errs.description ? 'spot-description' : errs.type ? 'spot-type' : null; if (!id) return; window.setTimeout(() => { document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); const focusable = document.querySelector(`#${id} input, #${id} textarea, #${id} button`); focusable?.focus({ preventScroll: true }); }, 0); }; 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 (!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); scrollToFirstError(nextErrors); return; } setErrors({}); // 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); window.setTimeout(() => { document.getElementById('spot-nearby')?.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 0); return; } } try { // Generate geohash from coordinates (7 chars = ~153m precision) const geohash = ngeohash.encode(lat, lng, 7); // Create a unique identifier for the addressable event. Non-Latin titles // still get a stable identifier instead of a bare timestamp. const slug = title .toLowerCase() .normalize('NFKD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '') .slice(0, 50); const identifier = `${slug || 'spot'}-${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); }), ]); const locationStr = location.trim() || [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. Preserve the real image type // where the URL exposes it; do not label every upload as JPEG. eventTags.push(['imeta', `url ${imageUrl}`, `m ${mimeForImageUrl(imageUrl)}`]); } // 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(); queryClient.invalidateQueries({ queryKey: ['skate-spots'] }); saveLastMapView(lat, lng, 15); toast({ title: t('spotUi.spotAdded'), description: t('spotUi.spotAddedDesc'), action: ( navigate(`/create-spot?lat=${lat.toFixed(6)}&lng=${lng.toFixed(6)}`)} > {t('home.addSpot')} ), }); // Stay in the map flow: the map opens on the new pin instead of sending // the contributor to a detail page they did not ask for. navigate('/map'); } 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')}

); } const nearestDuplicate = dupWarning?.[0]; const defaultCenter = coords ? ([coords.lat, coords.lng] as [number, number]) : (() => { const saved = getLastMapView(); if (saved) return [saved.lat, saved.lon] as [number, number]; const cached = getLastLocation(); return cached ? ([cached.lat, cached.lng] as [number, number]) : undefined; })(); 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')} {/* ── Photos first: most people already took the picture ─────── */}
{t('createSpot.photoHint')}
{images.map((url) => (
{t('spotUi.spotPhotoAlt')}
))}
{errors.photo &&

{errors.photo}

}
{/* ── Location (defaults to map/cached area; live GPS is explicit) */}
{!showLocationMap && hasCoords ? (

{location.trim() || `${lat.toFixed(5)}, ${lng.toFixed(5)}`}

{t('spotUi.exactPinPublic')}

) : ( <>
{t('createSpot.tapToPlace')} {hasCoords && ( )}

{t('spotUi.exactPinPublic')}

{ setLatitude(e.target.value); clearError('location'); }} inputMode="decimal" /> { setLongitude(e.target.value); clearError('location'); }} inputMode="decimal" />
)} {errors.location &&

{errors.location}

}
{/* Nearby existing spot: one calm shortcut, not a blocking modal. */} {nearestDuplicate && (

{t('spotUi.spotMayAlreadyExist')}

{t('spotUi.duplicateOne')}

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

{errors.title}

}
{/* ── Spot type: compact chips, one tap ─────────────────── */}
{spotCategories.map((category) => { const selected = selectedCategory === category.value; return ( ); })}
{errors.type &&

{errors.type}

}
{/* ── Note: one line is enough ───────────────────────────── */}