import { useState, useEffect } from 'react'; import { useSeoMeta } from '@unhead/react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import ngeohash from 'ngeohash'; import { Layout } from '@/components/Layout'; 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 { useToast } from '@/hooks/useToast'; import { useTranslation } from '@/contexts/language'; import { LoginArea } from '@/components/auth/LoginArea'; import { QualityMeter } from '@/components/QualityMeter'; import { TagInput } from '@/components/TagInput'; import { SKATE_SPOT_KIND } from '@/lib/types'; import { spotCategories, MAX_TAGS, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } from '@/lib/skateTags'; import { cn } from '@/lib/utils'; import { MapPin, Upload, 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([]); 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', }); } ); }; const handleImageUpload = async (e: React.ChangeEvent) => { const files = e.target.files; if (!files || files.length === 0) return; try { const file = files[0]; const tags = await uploadFile(file); const url = tags[0]?.[1]; if (url) { setImages((prev) => [...prev, url]); toast({ title: 'Image uploaded', description: 'Your image has been uploaded successfully.', }); } } catch (error) { toast({ title: 'Upload failed', description: error instanceof Error ? error.message : 'Failed to upload image', variant: 'destructive', }); } // Clear the input e.target.value = ''; }; // 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; } 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 // These power Sasquatch's city/country filtering (relay queries) let cityTag = ''; let countryTag = ''; let locationStr = location.trim(); try { const geoResp = await fetch( `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=10`, { headers: { 'Accept-Language': 'en', 'User-Agent': 'SkateSpots/1.0' } } ); if (geoResp.ok) { const geoData = await geoResp.json(); const addr = geoData.address ?? {}; cityTag = addr.city ?? addr.town ?? addr.village ?? addr.county ?? ''; countryTag = addr.country ?? ''; // If user didn't fill in location, auto-populate from geocoding if (!locationStr && cityTag) { locationStr = [cityTag, addr.state, countryTag].filter(Boolean).join(', '); } } } catch { // Geocoding is best-effort — don't block spot creation } // 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 tags — critical for Sasquatch relay search if (cityTag) { eventTags.push(['city', cityTag]); } if (countryTag) { eventTags.push(['country', countryTag]); } 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: '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 */}