feat(create-spot): pin map, progressive disclosure, mandatory photo+description, inline errors, draft autosave

This commit is contained in:
SkateSpots 2026-10-07 10:29:01 +07:00
parent 24322b3b1c
commit efd7e9608d
8 changed files with 650 additions and 341 deletions

View file

@ -1098,11 +1098,31 @@ session with **no other memory of this project**. So:
`SpotOwnership.tsx` (private branch) and a pending-claims list atop `/review`. `SpotOwnership.tsx` (private branch) and a pending-claims list atop `/review`.
⚠️ Proves key control + NIP-05, **not property ownership**. Non-private spots ⚠️ Proves key control + NIP-05, **not property ownership**. Non-private spots
keep the abandoned-claim flow. See NIP.md → "Private-spot ownership claims". keep the abandoned-claim flow. See NIP.md → "Private-spot ownership claims".
- **Photo is recommended, not required (2026-09-28):** the hard block in - **Photo + description are required again (2026-10-07, reverses 2026-09-28).**
`CreateSpotPage` was removed — it punished an honest mapper with no photo on Owner decision: the create-spot form now requires **≥1 photo AND a non-empty
hand. Publishing with no photo is allowed; the spot stays **Unverified** description** (on top of name/type/location) — the empty husk / one-word test
(`SpotDetailPage` badge), the success toast says so, and the review queue's pin can't publish. ⚠️ This is a **junk filter, not sybil defence**: a bot can
'no photo' flag lets a human look later. The pre-submit checklist still nudges. upload a random image; the real sybil levers remain PoW, WoT weighting and the
≥3-distinct-reporter auto-hide. The 2026-09-28 "photo recommended, not required"
decision is **superseded** — do not revert. No-photo publishing is gone.
- **Add-spot flow overhaul (2026-10-07).** Three parts, one commit:
(1) **Map tap no longer jumps** you into the form. `MapPage.handleMapClick`
drops a `pendingAddPin` (rendered by `SpotMap`'s `pendingPin` prop) and shows an
"Add a spot here?" card; only its **Add spot** button navigates to
`/create-spot?lat=&lng=`. (2) **The form has a real pin map** —
`src/components/SpotLocationPicker.tsx` (tap to drop, drag to fine-tune, cached
tiles). `createSpot.pickOnMap` was an **orphaned key** — the map picker had been
intended all along; raw lat/lng now live behind an "Enter coordinates manually"
toggle. (3) **Progressive disclosure + inline errors + draft autosave:** required
fields (map, name, description, type, photo) are up front; access/contact/shelter/
tags/address sit under a **More details** collapsible (access defaults public,
shelter defaults outdoor); field errors render inline (no toast per field); and
the form autosaves via **`useFormDraft`** (generalized from `useLabDraft`, same
resume-banner pattern). ⚠️ Leaflet's base CSS is **CDN-loaded in `index.html`**
(`src/main.tsx` just has a comment) — a new Leaflet map needs no CSS import.
Still open: the 60 m duplicate check still runs on submit with
`useSkateSpots(500)` (move it to pin-time + a targeted geohash query), and
publish still blocks on `reverseGeocodeRegions` before signing.
- **Map pin rendering (perf, 2026-09-29):** the spot/tour/event/crew marker - **Map pin rendering (perf, 2026-09-29):** the spot/tour/event/crew marker
effect in `SpotMap.tsx` used to `clearLayers()` + rebuild **every** marker effect in `SpotMap.tsx` used to `clearLayers()` + rebuild **every** marker
whenever the parent handed it fresh arrays, and added each marker with a whenever the parent handed it fresh arrays, and added each marker with a

View file

@ -0,0 +1,132 @@
import { useEffect, useRef } from 'react';
import L from 'leaflet';
import { createCachedTileLayer } from '@/lib/cachedTileLayer';
import { cn } from '@/lib/utils';
export interface LatLng {
lat: number;
lng: number;
}
interface SpotLocationPickerProps {
value: LatLng | null;
onChange: (coords: LatLng) => void;
className?: string;
}
/** Fallback center before the user has a location (NYC, same as the map default). */
const DEFAULT_CENTER: [number, number] = [40.7128, -74.006];
/**
* A small, tappable Leaflet map for choosing a spot's location.
*
* Tap anywhere to drop the pin, or drag the pin to fine-tune. This replaces the
* raw latitude/longitude text boxes as the PRIMARY way to set a location — those
* move behind an "enter coordinates manually" toggle for power users. Uses the
* same cached tile layer as the main map, so the basemap still draws offline.
*/
export function SpotLocationPicker({ value, onChange, className }: SpotLocationPickerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
const markerRef = useRef<L.Marker | null>(null);
const onChangeRef = useRef(onChange);
// Captured once so the init effect doesn't depend on `value` (which changes
// constantly) — the marker-sync effect below handles later updates.
const initialValueRef = useRef(value);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
// Create the map once.
useEffect(() => {
const el = containerRef.current;
if (!el || mapRef.current) return;
const initial = initialValueRef.current;
const center: [number, number] = initial ? [initial.lat, initial.lng] : DEFAULT_CENTER;
const map = L.map(el, {
center,
zoom: initial ? 15 : 12,
zoomControl: true,
attributionControl: true,
});
createCachedTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '',
subdomains: 'abc',
minZoom: 2,
maxZoom: 19,
className: 'skatespots-dark-tiles',
}).addTo(map);
map.on('click', (e: L.LeafletMouseEvent) => {
onChangeRef.current({ lat: e.latlng.lat, lng: e.latlng.lng });
});
mapRef.current = map;
const ro = new ResizeObserver(() => map.invalidateSize());
ro.observe(el);
// Leaflet can mis-measure inside a freshly-laid-out page — nudge it once.
const t = window.setTimeout(() => map.invalidateSize(), 0);
return () => {
window.clearTimeout(t);
ro.disconnect();
map.remove();
mapRef.current = null;
markerRef.current = null;
};
}, []);
// Draw / move the draggable pin to match `value`.
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (!value) {
if (markerRef.current) {
markerRef.current.remove();
markerRef.current = null;
}
return;
}
const latlng: [number, number] = [value.lat, value.lng];
if (markerRef.current) {
markerRef.current.setLatLng(latlng);
return;
}
const marker = L.marker(latlng, {
draggable: true,
icon: L.divIcon({
className: '',
html:
'<div style="display:flex;flex-direction:column;align-items:center;transform:translateY(-4px)">' +
'<div style="width:22px;height:22px;border-radius:50%;background:#ff0040;border:3px solid #fff;box-shadow:0 2px 10px rgba(0,0,0,.55)"></div>' +
'<div style="width:0;height:0;border-left:7px solid transparent;border-right:7px solid transparent;border-top:11px solid #fff;margin-top:-1px"></div>' +
'</div>',
iconSize: [22, 34],
iconAnchor: [11, 34],
}),
}).addTo(map);
marker.on('dragend', () => {
const p = marker.getLatLng();
onChangeRef.current({ lat: p.lat, lng: p.lng });
});
markerRef.current = marker;
map.setView(latlng, Math.max(map.getZoom(), 15), { animate: true });
}, [value]);
return (
<div
ref={containerRef}
className={cn('h-64 w-full overflow-hidden rounded-xl border-2 border-white/10', className)}
/>
);
}

67
src/hooks/useFormDraft.ts Normal file
View file

@ -0,0 +1,67 @@
import { useEffect, useRef, useState } from 'react';
/**
* Autosave a long form so it isn't lost on navigation or reload.
*
* - Persists `snapshot` to localStorage under `<prefix><key>` (content-debounced:
* the serialized string only changes when the draft actually changes).
* - A saved draft is only offered as **resumable** when it *differs* from the
* current form state — so merely opening an existing record (whose prefill
* matches the saved draft) never shows a spurious "resume" banner, and a
* draft can never silently clobber a freshly loaded record.
* - Call `clear()` after a successful publish.
*
* Used by the Lab profile forms (`useLabDraft`) and the create-spot form.
*/
export function useFormDraft<T>(
prefix: string,
key: string,
snapshot: T,
apply: (draft: T) => void,
) {
const storageKey = prefix + key;
const [loaded, setLoaded] = useState<T | null>(null);
const firstRun = useRef(true);
const serialized = JSON.stringify(snapshot);
// Load any saved draft once.
useEffect(() => {
try {
const raw = localStorage.getItem(storageKey);
if (raw) setLoaded(JSON.parse(raw) as T);
} catch {
// ignore malformed/blocked storage
}
}, [storageKey]);
// Persist on change (skip the initial empty paint).
useEffect(() => {
if (firstRun.current) {
firstRun.current = false;
return;
}
try {
localStorage.setItem(storageKey, serialized);
} catch {
// storage may be full/blocked — a lost draft is not fatal
}
}, [serialized, storageKey]);
const resume = () => {
if (!loaded) return;
apply(loaded);
setLoaded(null);
};
const discard = () => {
setLoaded(null);
try { localStorage.removeItem(storageKey); } catch { /* ignore */ }
};
const clear = () => {
try { localStorage.removeItem(storageKey); } catch { /* ignore */ }
};
const resumable = loaded !== null && JSON.stringify(loaded) !== serialized;
return { resumable, resume, discard, clear };
}

View file

@ -1,62 +1,11 @@
import { useEffect, useRef, useState } from 'react'; import { useFormDraft } from './useFormDraft';
const PREFIX = 'skatespots:lab-draft:'; const PREFIX = 'skatespots:lab-draft:';
/** /**
* Autosave a Lab profile form so a long create/edit isn't lost on nav or reload. * Autosave a Lab profile form. Thin wrapper over the shared `useFormDraft` —
* * kept as a named export so the existing Lab call sites don't change.
* - Persists `snapshot` to localStorage (content-debounced: the serialized
* string only changes when the draft actually changes).
* - A saved draft is only offered as **resumable** when it *differs* from the
* current form state — so merely opening an existing profile (whose prefill
* matches the saved draft) never shows a spurious "resume" banner, and a
* draft can never silently clobber a freshly loaded profile.
* - Call `clear()` after a successful publish.
*/ */
export function useLabDraft<T>(key: string, snapshot: T, apply: (draft: T) => void) { export function useLabDraft<T>(key: string, snapshot: T, apply: (draft: T) => void) {
const storageKey = PREFIX + key; return useFormDraft(PREFIX, key, snapshot, apply);
const [loaded, setLoaded] = useState<T | null>(null);
const firstRun = useRef(true);
const serialized = JSON.stringify(snapshot);
// Load any saved draft once.
useEffect(() => {
try {
const raw = localStorage.getItem(storageKey);
if (raw) setLoaded(JSON.parse(raw) as T);
} catch {
// ignore malformed/blocked storage
}
}, [storageKey]);
// Persist on change (skip the initial empty paint).
useEffect(() => {
if (firstRun.current) {
firstRun.current = false;
return;
}
try {
localStorage.setItem(storageKey, serialized);
} catch {
// storage may be full/blocked — a lost draft is not fatal
}
}, [serialized, storageKey]);
const resume = () => {
if (!loaded) return;
apply(loaded);
setLoaded(null);
};
const discard = () => {
setLoaded(null);
try { localStorage.removeItem(storageKey); } catch { /* ignore */ }
};
const clear = () => {
try { localStorage.removeItem(storageKey); } catch { /* ignore */ }
};
const resumable = loaded !== null && JSON.stringify(loaded) !== serialized;
return { resumable, resume, discard, clear };
} }

View file

@ -502,6 +502,18 @@ nav: {
useCurrentLocation: 'Use my current location', useCurrentLocation: 'Use my current location',
pickOnMap: 'Pick Location on Map', pickOnMap: 'Pick Location on Map',
loginToAdd: 'You need to log in to add a skate spot.', loginToAdd: 'You need to log in to add a skate spot.',
locationLabel: 'Location',
tapToPlace: 'Tap the map to place the pin, or drag it to fine-tune.',
manualCoords: 'Enter coordinates manually',
moreDetails: 'More details (optional)',
photoHint: 'a photo is required to publish',
nameRequired: 'Please enter a name for this spot.',
descriptionRequired: 'Please describe this spot.',
photoRequired: 'Please add at least one photo.',
draftTitle: 'You have an unsaved draft',
draftBody: 'Pick up where you left off, or discard it.',
draftResume: 'Resume',
draftDiscard: 'Discard draft',
requiredFields: 'Tell us about this spot. All fields marked with * are required.', requiredFields: 'Tell us about this spot. All fields marked with * are required.',
coordinates: 'Coordinates', coordinates: 'Coordinates',
latitudePlaceholder: 'Latitude (e.g., 34.1012)', latitudePlaceholder: 'Latitude (e.g., 34.1012)',

View file

@ -502,6 +502,18 @@ nav: {
useCurrentLocation: 'Usar mi ubicación actual', useCurrentLocation: 'Usar mi ubicación actual',
pickOnMap: 'Elegir Ubicación en el Mapa', pickOnMap: 'Elegir Ubicación en el Mapa',
loginToAdd: 'Necesitas iniciar sesión para añadir un spot de skate.', loginToAdd: 'Necesitas iniciar sesión para añadir un spot de skate.',
locationLabel: 'Ubicación',
tapToPlace: 'Toca el mapa para colocar el pin, o arrástralo para ajustarlo.',
manualCoords: 'Introducir coordenadas manualmente',
moreDetails: 'Más detalles (opcional)',
photoHint: 'se requiere una foto para publicar',
nameRequired: 'Ponle un nombre a este spot.',
descriptionRequired: 'Describe este spot.',
photoRequired: 'Añade al menos una foto.',
draftTitle: 'Tienes un borrador sin guardar',
draftBody: 'Continúa donde lo dejaste o descártalo.',
draftResume: 'Continuar',
draftDiscard: 'Descartar borrador',
requiredFields: 'Cuéntanos sobre este spot. Los campos con * son obligatorios.', requiredFields: 'Cuéntanos sobre este spot. Los campos con * son obligatorios.',
coordinates: 'Coordenadas', coordinates: 'Coordenadas',
latitudePlaceholder: 'Latitud (ej., 34.1012)', latitudePlaceholder: 'Latitud (ej., 34.1012)',

View file

@ -359,6 +359,22 @@ const thBatch5 = {
publishError: 'เผยแพร่อันนั้นไม่สำเร็จ: {{error}} ลองอีกครั้งในอีกสักครู่', publishError: 'เผยแพร่อันนั้นไม่สำเร็จ: {{error}} ลองอีกครั้งในอีกสักครู่',
unknownError: 'ข้อผิดพลาดไม่ทราบสาเหตุ', unknownError: 'ข้อผิดพลาดไม่ทราบสาเหตุ',
}, },
// Partial: only the keys this form added — the rest of createSpot still
// falls back to English until a full translation batch lands.
createSpot: {
locationLabel: 'ตำแหน่ง',
tapToPlace: 'แตะแผนที่เพื่อปักหมุด หรือลากหมุดเพื่อปรับให้ละเอียด',
manualCoords: 'กรอกพิกัดเอง',
moreDetails: 'รายละเอียดเพิ่มเติม (ไม่บังคับ)',
photoHint: 'ต้องมีรูปจึงจะเผยแพร่ได้',
nameRequired: 'กรุณาตั้งชื่อสปอตนี้',
descriptionRequired: 'กรุณาอธิบายสปอตนี้',
photoRequired: 'กรุณาเพิ่มรูปอย่างน้อยหนึ่งรูป',
draftTitle: 'คุณมีฉบับร่างที่ยังไม่ได้บันทึก',
draftBody: 'ทำต่อจากที่ค้างไว้ หรือลบทิ้ง',
draftResume: 'ทำต่อ',
draftDiscard: 'ลบฉบับร่าง',
},
}; };
export default thBatch5; export default thBatch5;

View file

@ -7,10 +7,12 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useUploadFile } from '@/hooks/useUploadFile'; import { useUploadFile } from '@/hooks/useUploadFile';
import { useSkateSpots } from '@/hooks/useSkateSpots'; import { useSkateSpots } from '@/hooks/useSkateSpots';
import { useFormDraft } from '@/hooks/useFormDraft';
import { calculateDistance } from '@/lib/sortOptions'; import { calculateDistance } from '@/lib/sortOptions';
import type { SkateSpot } from '@/lib/types'; import type { SkateSpot } from '@/lib/types';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
@ -19,6 +21,7 @@ import { LoginArea } from '@/components/auth/LoginArea';
import { QualityMeter } from '@/components/QualityMeter'; import { QualityMeter } from '@/components/QualityMeter';
import { PhotoDropzone } from '@/components/PhotoDropzone'; import { PhotoDropzone } from '@/components/PhotoDropzone';
import { TagInput } from '@/components/TagInput'; import { TagInput } from '@/components/TagInput';
import { SpotLocationPicker, type LatLng } from '@/components/SpotLocationPicker';
import { SKATE_SPOT_KIND } from '@/lib/types'; import { SKATE_SPOT_KIND } from '@/lib/types';
import { reverseGeocodeRegions, pushRegionTags } from '@/lib/geocodeRegions'; import { reverseGeocodeRegions, pushRegionTags } from '@/lib/geocodeRegions';
import { spotCategories, MAX_TAGS, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } from '@/lib/skateTags'; import { spotCategories, MAX_TAGS, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } from '@/lib/skateTags';
@ -31,15 +34,43 @@ import {
ArrowLeft, ArrowLeft,
Check, Check,
Camera, Camera,
ChevronDown,
Crosshair,
History,
} from 'lucide-react'; } from 'lucide-react';
import { CameraCapture } from '@/components/CameraCapture'; import { CameraCapture } from '@/components/CameraCapture';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
/** Per-field inline validation messages (no toast for field errors). */
type FieldErrors = {
title?: string;
description?: string;
location?: string;
type?: string;
photo?: string;
};
/** Shape of the autosaved draft. Keep every field optional on read so a draft
* saved before a new field existed can never crash the form. */
type SpotDraft = {
title: string;
description: string;
location: string;
latitude: string;
longitude: string;
selectedCategory: string;
tags: string[];
accessType: 'public' | 'private' | 'locals';
shelter: ShelterType;
contactInfo: string;
images: string[];
};
const DRAFT_PREFIX = 'skatespots:spot-draft:';
export default function CreateSpotPage() { export default function CreateSpotPage() {
const { t } = useTranslation(); const { t } = useTranslation();
useSeoMeta({ useSeoMeta({
title: `${t('createSpot.title')} - SkateSpots`, title: `${t('createSpot.title')} - SkateSpots`,
description: t('createSpot.subtitle'), description: t('createSpot.subtitle'),
@ -64,6 +95,9 @@ export default function CreateSpotPage() {
const [shelter, setShelter] = useState<ShelterType>(DEFAULT_SHELTER); const [shelter, setShelter] = useState<ShelterType>(DEFAULT_SHELTER);
const [contactInfo, setContactInfo] = useState(''); const [contactInfo, setContactInfo] = useState('');
const [images, setImages] = useState<string[]>([]); 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 // 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 // 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. // the same spot) — and it points the contributor at the existing pin instead.
@ -76,15 +110,51 @@ export default function CreateSpotPage() {
// on this form. // on this form.
const [isUploadingQuickPhoto, setIsUploadingQuickPhoto] = useState(false); const [isUploadingQuickPhoto, setIsUploadingQuickPhoto] = useState(false);
// Pre-fill coordinates from URL params (from map click) // 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));
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(() => { useEffect(() => {
const lat = searchParams.get('lat'); const latParam = searchParams.get('lat');
const lng = searchParams.get('lng'); const lngParam = searchParams.get('lng');
if (lat && lng) { if (latParam && lngParam) {
setLatitude(lat); setLatitude(latParam);
setLongitude(lng); setLongitude(lngParam);
} }
// Check for quick capture image from map page // Check for quick capture image from map page
const quickCapture = searchParams.get('quickCapture'); const quickCapture = searchParams.get('quickCapture');
if (quickCapture === 'true') { if (quickCapture === 'true') {
@ -130,6 +200,7 @@ export default function CreateSpotPage() {
const handleCategoryChange = (category: string) => { const handleCategoryChange = (category: string) => {
setSelectedCategory(category); setSelectedCategory(category);
clearError('type');
}; };
const getCurrentLocation = () => { const getCurrentLocation = () => {
@ -148,6 +219,7 @@ export default function CreateSpotPage() {
setLatitude(position.coords.latitude.toFixed(6)); setLatitude(position.coords.latitude.toFixed(6));
setLongitude(position.coords.longitude.toFixed(6)); setLongitude(position.coords.longitude.toFixed(6));
setIsGettingLocation(false); setIsGettingLocation(false);
clearError('location');
toast({ toast({
title: t('spotUi.locationFound'), title: t('spotUi.locationFound'),
description: t('spotUi.locationFoundDesc'), description: t('spotUi.locationFoundDesc'),
@ -179,6 +251,7 @@ export default function CreateSpotPage() {
} }
} }
if (added > 0) { if (added > 0) {
clearError('photo');
toast({ toast({
title: added > 1 ? t('spotUi.photosUploaded', { count: added }) : t('spotUi.imageUploaded'), title: added > 1 ? t('spotUi.photosUploaded', { count: added }) : t('spotUi.imageUploaded'),
description: description:
@ -202,6 +275,7 @@ export default function CreateSpotPage() {
if (url) { if (url) {
setImages((prev) => [...prev, url]); setImages((prev) => [...prev, url]);
setShowCamera(false); setShowCamera(false);
clearError('photo');
toast({ toast({
title: t('spotUi.photoCaptured'), title: t('spotUi.photoCaptured'),
description: t('spotUi.photoCapturedDesc'), description: t('spotUi.photoCapturedDesc'),
@ -223,44 +297,21 @@ export default function CreateSpotPage() {
const handleSubmit = async (e?: React.FormEvent) => { const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault(); e?.preventDefault();
if (!title.trim()) { // Inline validation — collect every missing required field at once instead
toast({ // of firing one toast at a time. Photo + description are mandatory: they are
title: t('spotUi.titleRequired'), // the cheapest defence against test pins and empty husks.
description: t('spotUi.titleRequiredDesc'), const nextErrors: FieldErrors = {};
variant: 'destructive', if (!title.trim()) nextErrors.title = t('createSpot.nameRequired');
}); if (!description.trim()) nextErrors.description = t('createSpot.descriptionRequired');
return; if (!hasCoords) nextErrors.location = t('spotUi.locationRequiredDesc');
} if (!selectedCategory) nextErrors.type = t('spotUi.spotTypeRequiredDesc');
if (images.length === 0) nextErrors.photo = t('createSpot.photoRequired');
if (!latitude || !longitude) {
toast({ if (Object.keys(nextErrors).length > 0) {
title: t('spotUi.locationRequired'), setErrors(nextErrors);
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; return;
} }
setErrors({});
// Don't publish while the Quick Snap photo is still uploading — otherwise the // Don't publish while the Quick Snap photo is still uploading — otherwise the
// spot would go out without the photo the user just took. // spot would go out without the photo the user just took.
@ -272,19 +323,6 @@ export default function CreateSpotPage() {
return; 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. // Look for an existing spot within 60 m and ask before creating a clone.
if (!dupConfirmedRef.current) { if (!dupConfirmedRef.current) {
const nearby = (existingSpots ?? []) const nearby = (existingSpots ?? [])
@ -368,7 +406,7 @@ export default function CreateSpotPage() {
// Also add as t tag for backwards compatibility and relay filtering // Also add as t tag for backwards compatibility and relay filtering
eventTags.push(['t', selectedCategory]); eventTags.push(['t', selectedCategory]);
} }
// Add all freeform tags // Add all freeform tags
for (const tag of tags) { for (const tag of tags) {
eventTags.push(['t', tag]); eventTags.push(['t', tag]);
@ -379,7 +417,7 @@ export default function CreateSpotPage() {
// Use imeta tag for NIP-92 compatibility // Use imeta tag for NIP-92 compatibility
eventTags.push(['imeta', `url ${imageUrl}`, 'm image/jpeg']); eventTags.push(['imeta', `url ${imageUrl}`, 'm image/jpeg']);
} }
// Add alt tag for NIP-31 (human-readable description) // Add alt tag for NIP-31 (human-readable description)
eventTags.push(['alt', t('spotUi.altSkateSpot', { title: title.trim() })]); eventTags.push(['alt', t('spotUi.altSkateSpot', { title: title.trim() })]);
@ -389,11 +427,11 @@ export default function CreateSpotPage() {
tags: eventTags, tags: eventTags,
}); });
draft.clear();
toast({ toast({
title: t('spotUi.spotAdded'), title: t('spotUi.spotAdded'),
description: images.length === 0 description: t('spotUi.spotAddedDesc'),
? t('spotUi.spotAddedNoPhoto')
: t('spotUi.spotAddedDesc'),
}); });
// Navigate to spot detail using pubkey and identifier // Navigate to spot detail using pubkey and identifier
@ -443,6 +481,32 @@ export default function CreateSpotPage() {
</p> </p>
</div> </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}> <form onSubmit={handleSubmit}>
<Card className="bg-black/90 border-2 border-[#ff0040]/30"> <Card className="bg-black/90 border-2 border-[#ff0040]/30">
<CardHeader> <CardHeader>
@ -452,188 +516,93 @@ export default function CreateSpotPage() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* Title */} {/* ── 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>
{/* ── Title ─────────────────────────────────────────────── */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="title">{t('spots.spotName')} *</Label> <Label htmlFor="title">{t('spots.spotName')} *</Label>
<Input <Input
id="title" id="title"
placeholder={t('createSpot.namePlaceholder')} placeholder={t('createSpot.namePlaceholder')}
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => { setTitle(e.target.value); clearError('title'); }}
required className={cn(errors.title && 'border-red-500/60')}
/> />
{errors.title && <p className="text-xs font-medium text-red-400">{errors.title}</p>}
</div> </div>
{/* Description */} {/* ── Description ───────────────────────────────────────── */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description">{t('spots.description')}</Label> <Label htmlFor="description">{t('spots.description')} *</Label>
<Textarea <Textarea
id="description" id="description"
placeholder={t('createSpot.descriptionPlaceholder')} placeholder={t('createSpot.descriptionPlaceholder')}
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => { setDescription(e.target.value); clearError('description'); }}
rows={4} 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> </div>
{/* Location Address */} {/* ── Spot type ─────────────────────────────────────────── */}
<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"> <div className="space-y-3">
<Label className="text-base font-bold">{t('spots.spotType')} *</Label> <Label className="text-base font-bold">{t('spots.spotType')} *</Label>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
@ -660,65 +629,15 @@ export default function CreateSpotPage() {
</Card> </Card>
))} ))}
</div> </div>
{errors.type && <p className="text-xs font-medium text-red-400">{errors.type}</p>}
</div> </div>
{/* Shelter / weather protection */} {/* ── Photos (required) ─────────────────────────────────── */}
<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>
{/* Images — required (see the guard in handleSubmit). */}
<div className="space-y-2"> <div className="space-y-2">
<Label> <Label>
{t('spots.photos')} {t('spots.photos')} *
<span className="ml-2 text-[11px] font-normal text-muted-foreground"> <span className="ml-2 text-[11px] font-normal text-muted-foreground">
{t('spotUi.photosRecommended')} {t('createSpot.photoHint')}
</span> </span>
</Label> </Label>
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
@ -760,6 +679,7 @@ export default function CreateSpotPage() {
className="aspect-video" className="aspect-video"
/> />
</div> </div>
{errors.photo && <p className="text-xs font-medium text-red-400">{errors.photo}</p>}
</div> </div>
{/* Camera Capture Dialog */} {/* Camera Capture Dialog */}
@ -770,10 +690,191 @@ export default function CreateSpotPage() {
isUploading={isUploading} isUploading={isUploading}
/> />
{/* ── 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">
{/* 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>
{/* 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>
)}
{/* 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 */} {/* Quality Meter */}
<QualityMeter <QualityMeter
type="spot" type="spot"
hasLocation={!!(latitude && longitude)} hasLocation={hasCoords}
hasPhotos={images.length > 0} hasPhotos={images.length > 0}
photoCount={images.length} photoCount={images.length}
hasDescription={description.trim().length > 0} hasDescription={description.trim().length > 0}