SkateSpots/src/pages/CreateSpotPage.tsx
SkateSpots 9fe219aac6 feat(stats): reconcile the region card, and geocode shops too
Answers 'why don't the map and stats match' by making the gap visible, and
brings shops into the region breakdown.

- src/lib/geocodeRegions.ts is now the single reverse-geocode helper (spot
  create, shop create, shop edit), so the Nominatim call + Accept-Language + the
  parse rules can't drift. Never throws.
- Shops now publish city/country/country_code (previously none), so they can
  appear in the region breakdown.
- Stats: regions cover spots AND shops; a new 'Uncategorised (N)' row plus a
  '{N} of {M}' footnote let the card reconcile with the location total. Section
  retitled 'Spots & Shops by Region'.
- PERSIST_BUSTER v3 -> v4.
- /privacy: wording now 'spot or shop geocoding'.

The map deliberately still reads the community relay + the reader's relays
(forcing canonical would hide an opted-out user's own spots) - documented.
2026-10-01 13:27:44 +07:00

859 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string>('');
const [tags, setTags] = useState<string[]>([]);
const [accessType, setAccessType] = useState<'public' | 'private' | 'locals'>('public');
const [shelter, setShelter] = useState<ShelterType>(DEFAULT_SHELTER);
const [contactInfo, setContactInfo] = useState('');
const [images, setImages] = useState<string[]>([]);
// 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<Array<{ spot: SkateSpot; km: number }> | 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 (
<>
<div className="container px-4 py-8">
<Card className="max-w-xl mx-auto border-dashed">
<CardContent className="py-12 text-center">
<MapPin className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
<h2 className="text-xl font-bold mb-2">{t('errors.loginRequired')}</h2>
<p className="text-muted-foreground mb-6">
{t('createSpot.loginToAdd')}
</p>
<LoginArea className="justify-center" />
</CardContent>
</Card>
</div>
</>
);
}
return (
<>
<div className="container px-4 sm:px-6 py-6 sm:py-8 max-w-2xl">
<div className="mb-6">
<Link to="/map" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4">
<ArrowLeft className="h-4 w-4 mr-2" />
{t('createSpot.backToMap')}
</Link>
<h1 className="text-3xl font-black uppercase neon-red" style={{
fontFamily: 'Impact, "Arial Black", sans-serif'
}}>{t('createSpot.title')}</h1>
<p className="text-gray-400 mt-1">
{t('createSpot.subtitle')}
</p>
</div>
<form onSubmit={handleSubmit}>
<Card className="bg-black/90 border-2 border-[#ff0040]/30">
<CardHeader>
<CardTitle className="text-[#ff0040] uppercase font-black">{t('spots.spotDetails')}</CardTitle>
<CardDescription className="text-gray-400">
{t('createSpot.requiredFields')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Title */}
<div className="space-y-2">
<Label htmlFor="title">{t('spots.spotName')} *</Label>
<Input
id="title"
placeholder={t('createSpot.namePlaceholder')}
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">{t('spots.description')}</Label>
<Textarea
id="description"
placeholder={t('createSpot.descriptionPlaceholder')}
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
/>
</div>
{/* Location Address */}
<div className="space-y-2">
<Label htmlFor="location">{t('spots.address')}</Label>
<Input
id="location"
placeholder={t('createSpot.addressPlaceholder')}
value={location}
onChange={(e) => setLocation(e.target.value)}
/>
</div>
{/* Coordinates */}
<div className="space-y-2">
<Label>{t('createSpot.coordinates')} *</Label>
<div className="grid grid-cols-2 gap-4">
<div>
<Input
placeholder={t('createSpot.latitudePlaceholder')}
value={latitude}
onChange={(e) => setLatitude(e.target.value)}
required
/>
</div>
<div>
<Input
placeholder={t('createSpot.longitudePlaceholder')}
value={longitude}
onChange={(e) => setLongitude(e.target.value)}
required
/>
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={getCurrentLocation}
disabled={isGettingLocation}
className="mt-2"
>
{isGettingLocation ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t('createSpot.gettingLocation')}
</>
) : (
<>
<MapPin className="h-4 w-4 mr-2" />
{t('createSpot.useCurrentLocation')}
</>
)}
</Button>
</div>
{/* Access Type */}
<div className="space-y-3">
<Label className="text-base font-bold">{t('createSpot.accessType')} *</Label>
<div className="space-y-2">
{/* Public */}
<Card
className={cn(
"cursor-pointer transition-all border-2",
accessType === 'public'
? "bg-[#ff0040]/10 border-[#ff0040] ring-2 ring-[#ff0040]/50"
: "border-gray-700 hover:border-[#ff0040]/50"
)}
onClick={() => setAccessType('public')}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="text-2xl">🌍</div>
<div className="flex-1">
<h4 className="font-bold text-white mb-1">{t('createSpot.publicTitle')}</h4>
<p className="text-sm text-gray-400">{t('createSpot.publicDesc')}</p>
</div>
{accessType === 'public' && (
<div className="w-6 h-6 rounded-full bg-[#ff0040] flex items-center justify-center">
<span className="text-white text-xs">✓</span>
</div>
)}
</div>
</CardContent>
</Card>
{/* Private */}
<Card
className={cn(
"cursor-pointer transition-all border-2",
accessType === 'private'
? "bg-[#ff6600]/10 border-[#ff6600] ring-2 ring-[#ff6600]/50"
: "border-gray-700 hover:border-[#ff6600]/50"
)}
onClick={() => setAccessType('private')}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="text-2xl">🔒</div>
<div className="flex-1">
<h4 className="font-bold text-white mb-1">{t('createSpot.privateTitle')}</h4>
<p className="text-sm text-gray-400">{t('createSpot.privateDesc')}</p>
</div>
{accessType === 'private' && (
<div className="w-6 h-6 rounded-full bg-[#ff6600] flex items-center justify-center">
<span className="text-white text-xs">✓</span>
</div>
)}
</div>
</CardContent>
</Card>
{/* Ask Locals */}
<Card
className={cn(
"cursor-pointer transition-all border-2",
accessType === 'locals'
? "bg-[#ccff00]/10 border-[#ccff00] ring-2 ring-[#ccff00]/50"
: "border-gray-700 hover:border-[#ccff00]/50"
)}
onClick={() => setAccessType('locals')}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="text-2xl">👥</div>
<div className="flex-1">
<h4 className="font-bold text-white mb-1">{t('createSpot.localsTitle')}</h4>
<p className="text-sm text-gray-400">{t('createSpot.localsDesc')}</p>
</div>
{accessType === 'locals' && (
<div className="w-6 h-6 rounded-full bg-[#ccff00] flex items-center justify-center">
<span className="text-black text-xs">✓</span>
</div>
)}
</div>
</CardContent>
</Card>
</div>
</div>
{/* Contact Info for Private/Locals */}
{(accessType === 'private' || accessType === 'locals') && (
<div className="space-y-2">
<Label htmlFor="contact">{t('createSpot.contactInfo')}</Label>
<Input
id="contact"
placeholder={t('createSpot.contactPlaceholder')}
value={contactInfo}
onChange={(e) => setContactInfo(e.target.value)}
className="bg-black border-[#ff6600]/30"
/>
<p className="text-xs text-gray-400">
{accessType === 'private'
? t('createSpot.contactHintPrivate')
: t('createSpot.contactHintLocals')}
</p>
</div>
)}
{/* Spot Category (required, mutually exclusive) */}
<div className="space-y-3">
<Label className="text-base font-bold">{t('spots.spotType')} *</Label>
<div className="grid grid-cols-2 gap-2">
{spotCategories.map((category) => (
<Card
key={category.value}
className={cn(
"cursor-pointer transition-all border-2 touch-manipulation active:scale-[0.98]",
selectedCategory === category.value
? "bg-[#ff0040]/10 border-[#ff0040] ring-2 ring-[#ff0040]/50"
: "border-gray-700 hover:border-[#ff0040]/50"
)}
onClick={() => handleCategoryChange(category.value)}
>
<CardContent className="p-3 sm:p-4 text-center relative">
<div className="font-bold text-sm sm:text-base">{category.label}</div>
<div className="text-xs text-gray-400 mt-1">{category.description}</div>
{selectedCategory === category.value && (
<div className="absolute top-1 right-1 w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-[#ff0040] flex items-center justify-center">
<Check className="h-2.5 w-2.5 sm:h-3 sm:w-3 text-white" />
</div>
)}
</CardContent>
</Card>
))}
</div>
</div>
{/* Shelter / weather protection */}
<div className="space-y-3">
<Label className="text-base font-bold">Shelter</Label>
<p className="text-xs text-gray-400 -mt-1">
Is this spot exposed to the weather? Helps skaters find dry spots when it rains.
</p>
<div className="grid grid-cols-2 gap-2">
{shelterOptions.map((option) => (
<Card
key={option.value}
className={cn(
"cursor-pointer transition-all border-2 touch-manipulation active:scale-[0.98]",
shelter === option.value
? "bg-[#ff0040]/10 border-[#ff0040] ring-2 ring-[#ff0040]/50"
: "border-gray-700 hover:border-[#ff0040]/50"
)}
onClick={() => setShelter(option.value)}
>
<CardContent className="p-3 sm:p-4 relative">
<div className="flex items-start gap-2.5">
<div className="text-xl leading-none mt-0.5">{option.emoji}</div>
<div className="flex-1 min-w-0">
<div className="font-bold text-sm sm:text-base text-white">{option.label}</div>
<div className="text-xs text-gray-400 mt-0.5">{option.description}</div>
</div>
</div>
{shelter === option.value && (
<div className="absolute top-1 right-1 w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-[#ff0040] flex items-center justify-center">
<Check className="h-2.5 w-2.5 sm:h-3 sm:w-3 text-white" />
</div>
)}
</CardContent>
</Card>
))}
</div>
</div>
{/* Freeform Tags */}
<div className="space-y-3">
<Label className="text-base font-bold">Tags</Label>
<p className="text-xs text-gray-400 -mt-1">
Add tags for features, surfaces, vibes (max {MAX_TAGS})
</p>
<TagInput
tags={tags}
onChange={setTags}
placeholder="Type to search or add tags..."
maxTags={MAX_TAGS}
/>
</div>
{/* Images — required (see the guard in handleSubmit). */}
<div className="space-y-2">
<Label>
{t('spots.photos')}
<span className="ml-2 text-[11px] font-normal text-muted-foreground">
strongly recommended — spots without a photo stay marked Unverified
</span>
</Label>
<div className="grid grid-cols-3 gap-4">
{images.map((url) => (
<div key={url} className="relative aspect-video rounded-lg overflow-hidden bg-muted">
<img src={url} alt="Spot" className="w-full h-full object-cover" />
<button
type="button"
onClick={() => removeImage(url)}
className="absolute top-1 right-1 p-1 rounded-full bg-background/80 hover:bg-destructive hover:text-destructive-foreground transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
))}
{/* Quick Snap photo uploading in the background */}
{isUploadingQuickPhoto && (
<div className="relative aspect-video rounded-lg overflow-hidden bg-muted flex flex-col items-center justify-center border-2 border-dashed border-[#ccff00]/50">
<Loader2 className="h-6 w-6 animate-spin text-[#ccff00] mb-1" />
<span className="text-xs text-[#ccff00] font-bold">Uploading photo…</span>
</div>
)}
{/* Camera capture button */}
<button
type="button"
onClick={() => setShowCamera(true)}
disabled={isUploading}
className="aspect-video rounded-lg border-2 border-dashed border-[#ff0040]/50 flex flex-col items-center justify-center cursor-pointer hover:bg-[#ff0040]/10 hover:border-[#ff0040] transition-colors"
>
<Camera className="h-6 w-6 text-[#ff0040] mb-1" />
<span className="text-xs text-[#ff0040] font-bold">Take Photo</span>
</button>
{/* File upload / drop zone — accepts several photos at once */}
<PhotoDropzone
onFiles={uploadImageFiles}
uploading={isUploading}
label={t('createSpot.uploadPhotos')}
hint={t('createSpot.dragDrop')}
className="aspect-video"
/>
</div>
</div>
{/* Camera Capture Dialog */}
<CameraCapture
open={showCamera}
onOpenChange={setShowCamera}
onCapture={handleCameraCapture}
isUploading={isUploading}
/>
{/* Quality Meter */}
<QualityMeter
type="spot"
hasLocation={!!(latitude && longitude)}
hasPhotos={images.length > 0}
photoCount={images.length}
hasDescription={description.trim().length > 0}
descriptionLength={description.trim().length}
hasSpotType={!!selectedCategory}
/>
{dupWarning && (
<div className="rounded-2xl border-2 border-amber-500/40 bg-amber-500/10 p-4">
<p className="text-sm font-black uppercase tracking-wide text-amber-300">
This spot may already exist
</p>
<p className="mt-1 text-xs text-gray-300">
{dupWarning.length === 1 ? 'There is a spot' : 'There are spots'} within 60 m of
your pin. If it's the same place, add your photo or a status report there
instead of creating a duplicate.
</p>
<ul className="mt-2 space-y-1">
{dupWarning.map(({ spot, km }) => (
<li key={spot.id} className="truncate text-xs">
<Link
to={`/spot/${spot.pubkey}/${spot.identifier}`}
className="text-[#ccff00] hover:opacity-80"
>
{spot.title || 'Untitled spot'} · {Math.round(km * 1000)} m away
</Link>
</li>
))}
</ul>
<div className="mt-3 flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
className="border-white/20"
onClick={() => {
dupConfirmedRef.current = true;
setDupWarning(null);
void handleSubmit();
}}
>
It's a different spot — publish anyway
</Button>
<Button
type="button"
variant="ghost"
className="text-gray-400"
onClick={() => setDupWarning(null)}
>
Cancel
</Button>
</div>
</div>
)}
{/* Submit */}
<Button
type="submit"
className="w-full"
disabled={isPublishing || isUploading || isUploadingQuickPhoto}
>
{isPublishing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{t('createSpot.publishing')}
</>
) : isUploadingQuickPhoto ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Uploading photo…
</>
) : (
<>
<MapPin className="h-4 w-4 mr-2" />
{t('home.addSpot')}
</>
)}
</Button>
</CardContent>
</Card>
</form>
</div>
</>
);
}