1004 lines
43 KiB
TypeScript
1004 lines
43 KiB
TypeScript
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<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[]>([]);
|
|
const [errors, setErrors] = useState<FieldErrors>({});
|
|
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<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);
|
|
|
|
// 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<SpotDraft>(
|
|
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<GeocodedRegions>((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 (
|
|
<>
|
|
<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>
|
|
|
|
{/* Unsaved-draft banner */}
|
|
{draft.resumable && (
|
|
<div className="mb-4 flex items-center gap-3 rounded-xl border border-[#ccff00]/40 bg-[#ccff00]/5 p-3">
|
|
<History className="h-5 w-5 flex-shrink-0 text-[#ccff00]" />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-bold text-white">{t('createSpot.draftTitle')}</p>
|
|
<p className="text-xs text-gray-400">{t('createSpot.draftBody')}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={draft.resume}
|
|
className="min-h-0 flex-shrink-0 rounded-lg bg-[#ccff00] px-3 py-1.5 text-xs font-black uppercase text-black hover:bg-[#ccff00]/90"
|
|
>
|
|
{t('createSpot.draftResume')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={draft.discard}
|
|
aria-label={t('createSpot.draftDiscard')}
|
|
className="min-h-0 min-w-0 flex-shrink-0 rounded-full p-1 text-gray-500 hover:bg-white/10 hover:text-white"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</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">
|
|
{/* ── Location (map picker) ─────────────────────────────── */}
|
|
<div className="space-y-2">
|
|
<Label className="text-base font-bold">{t('createSpot.locationLabel')} *</Label>
|
|
<SpotLocationPicker value={coords} onChange={handlePick} />
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={getCurrentLocation}
|
|
disabled={isGettingLocation}
|
|
>
|
|
{isGettingLocation ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
{t('createSpot.gettingLocation')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Crosshair className="h-4 w-4 mr-2" />
|
|
{t('createSpot.useCurrentLocation')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
<span className="text-xs text-gray-400">{t('createSpot.tapToPlace')}</span>
|
|
</div>
|
|
{errors.location && (
|
|
<p className="text-xs font-medium text-red-400">{errors.location}</p>
|
|
)}
|
|
|
|
<Collapsible open={showManualCoords} onOpenChange={setShowManualCoords}>
|
|
<CollapsibleTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="flex items-center gap-1 text-xs font-bold text-gray-400 hover:text-white transition-colors"
|
|
>
|
|
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', showManualCoords && 'rotate-180')} />
|
|
{t('createSpot.manualCoords')}
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="pt-3">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Input
|
|
placeholder={t('createSpot.latitudePlaceholder')}
|
|
value={latitude}
|
|
onChange={(e) => { setLatitude(e.target.value); clearError('location'); }}
|
|
inputMode="decimal"
|
|
/>
|
|
<Input
|
|
placeholder={t('createSpot.longitudePlaceholder')}
|
|
value={longitude}
|
|
onChange={(e) => { setLongitude(e.target.value); clearError('location'); }}
|
|
inputMode="decimal"
|
|
/>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</div>
|
|
|
|
{/* Address (required) — the human-readable place name. */}
|
|
<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); clearError('address'); }}
|
|
className={cn(errors.address && 'border-red-500/60')}
|
|
/>
|
|
{errors.address && <p className="text-xs font-medium text-red-400">{errors.address}</p>}
|
|
</div>
|
|
|
|
{/* Duplicate warning — appears as soon as the pin lands, right
|
|
below the map, instead of only on submit. */}
|
|
{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);
|
|
}}
|
|
>
|
|
{t('spotUi.differentSpotPublishAnyway')}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
className="text-gray-400"
|
|
onClick={() => setDupWarning(null)}
|
|
>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── 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); clearError('title'); }}
|
|
className={cn(errors.title && 'border-red-500/60')}
|
|
/>
|
|
{errors.title && <p className="text-xs font-medium text-red-400">{errors.title}</p>}
|
|
</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); clearError('description'); }}
|
|
rows={4}
|
|
className={cn(errors.description && 'border-red-500/60')}
|
|
/>
|
|
{errors.description && <p className="text-xs font-medium text-red-400">{errors.description}</p>}
|
|
</div>
|
|
|
|
{/* ── Spot type ─────────────────────────────────────────── */}
|
|
<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">{SPOT_TAG_CATEGORY_KEYS[category.value] ? t(SPOT_TAG_CATEGORY_KEYS[category.value]) : category.label}</div>
|
|
<div className="text-xs text-gray-400 mt-1">{SPOT_TAG_CATEGORY_DESC_KEYS[category.value] ? t(SPOT_TAG_CATEGORY_DESC_KEYS[category.value]) : 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>
|
|
{errors.type && <p className="text-xs font-medium text-red-400">{errors.type}</p>}
|
|
</div>
|
|
|
|
{/* ── Photos (required) ─────────────────────────────────── */}
|
|
<div className="space-y-2">
|
|
<Label>
|
|
{t('spots.photos')} *
|
|
<span className="ml-2 text-[11px] font-normal text-muted-foreground">
|
|
{t('createSpot.photoHint')}
|
|
</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>
|
|
{errors.photo && <p className="text-xs font-medium text-red-400">{errors.photo}</p>}
|
|
</div>
|
|
|
|
{/* Camera Capture Dialog */}
|
|
<CameraCapture
|
|
open={showCamera}
|
|
onOpenChange={setShowCamera}
|
|
onCapture={handleCameraCapture}
|
|
isUploading={isUploading}
|
|
/>
|
|
|
|
{/* 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>
|
|
)}
|
|
|
|
{/* ── More details (optional) ───────────────────────────── */}
|
|
<Collapsible open={showMore} onOpenChange={setShowMore} className="rounded-xl border border-white/10">
|
|
<CollapsibleTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center justify-between gap-2 p-4 text-left"
|
|
>
|
|
<span className="text-sm font-black uppercase tracking-wide text-white">
|
|
{t('createSpot.moreDetails')}
|
|
</span>
|
|
<ChevronDown className={cn('h-4 w-4 text-gray-400 transition-transform', showMore && 'rotate-180')} />
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="space-y-6 px-4 pb-4">
|
|
{/* 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">{SHELTER_KEYS[option.value] ? t(SHELTER_KEYS[option.value]) : option.label}</div>
|
|
<div className="text-xs text-gray-400 mt-0.5">{SHELTER_DESC_KEYS[option.value] ? t(SHELTER_DESC_KEYS[option.value]) : 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>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
|
|
{/* Quality Meter */}
|
|
<QualityMeter
|
|
type="spot"
|
|
hasLocation={hasCoords}
|
|
hasPhotos={images.length > 0}
|
|
photoCount={images.length}
|
|
hasDescription={description.trim().length > 0}
|
|
descriptionLength={description.trim().length}
|
|
hasSpotType={!!selectedCategory}
|
|
/>
|
|
|
|
{/* 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>
|
|
</>
|
|
);
|
|
}
|