SkateSpots/src/pages/CreateSpotPage.tsx
SkateSpots 09735375c2 feat(i18n): Spanish (Castellano) — infra + Map/Spots, Marketplace, Lab (batch 1)
- First-visit language auto-detect (Spanish devices land in es; stored choice wins).
- Sasquatch AI replies in the app language (skate trick names kept English).
- i18n safety net: scripts/check-i18n.mjs wired into npm test — fails on any t()
  key missing from en/es or any en/es key drift.
- ~1,680 new keys (2,297 total) across mapUi / spotUi / marketUi / labUi and
  ~65 files wired to t(): Map, spots (detail/create/edit/chat), marketplace,
  trades, offers, escrow, shipping, The Lab, /become/* forms, coaches, skaters.
- Policy: only skate TRICK names stay English; user-generated content untouched.
2026-10-01 23:57:20 +07:00

859 lines
34 KiB
TypeScript

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<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: 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 (
<>
<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">{t('spotUi.shelter')}</Label>
<p className="text-xs text-gray-400 -mt-1">
{t('spotUi.shelterHint')}
</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">{t('spotUi.tags')}</Label>
<p className="text-xs text-gray-400 -mt-1">
{t('spotUi.tagsHint', { max: MAX_TAGS })}
</p>
<TagInput
tags={tags}
onChange={setTags}
placeholder={t('spotUi.typeToSearchTags')}
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">
{t('spotUi.photosRecommended')}
</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={t('spotUi.spotPhotoAlt')} 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">{t('spotUi.uploadingPhoto')}</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">{t('spotUi.takePhoto')}</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">
{t('spotUi.spotMayAlreadyExist')}
</p>
<p className="mt-1 text-xs text-gray-300">
{dupWarning.length === 1
? t('spotUi.duplicateOne')
: t('spotUi.duplicateOther')}
</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 || t('spotUi.untitledSpot')} · {t('spotUi.metresAway', { distance: Math.round(km * 1000) })}
</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();
}}
>
{t('spotUi.differentSpotPublishAnyway')}
</Button>
<Button
type="button"
variant="ghost"
className="text-gray-400"
onClick={() => setDupWarning(null)}
>
{t('common.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" />
{t('spotUi.uploadingPhoto')}
</>
) : (
<>
<MapPin className="h-4 w-4 mr-2" />
{t('home.addSpot')}
</>
)}
</Button>
</CardContent>
</Card>
</form>
</div>
</>
);
}