diff --git a/AGENTS.md b/AGENTS.md index d74bb3c..aaa7947 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1098,11 +1098,31 @@ session with **no other memory of this project**. So: `SpotOwnership.tsx` (private branch) and a pending-claims list atop `/review`. ⚠️ Proves key control + NIP-05, **not property ownership**. Non-private spots keep the abandoned-claim flow. See NIP.md → "Private-spot ownership claims". -- **Photo is recommended, not required (2026-09-28):** the hard block in - `CreateSpotPage` was removed — it punished an honest mapper with no photo on - hand. Publishing with no photo is allowed; the spot stays **Unverified** - (`SpotDetailPage` badge), the success toast says so, and the review queue's - 'no photo' flag lets a human look later. The pre-submit checklist still nudges. +- **Photo + description are required again (2026-10-07, reverses 2026-09-28).** + Owner decision: the create-spot form now requires **≥1 photo AND a non-empty + description** (on top of name/type/location) — the empty husk / one-word test + pin can't publish. ⚠️ This is a **junk filter, not sybil defence**: a bot can + 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 effect in `SpotMap.tsx` used to `clearLayers()` + rebuild **every** marker whenever the parent handed it fresh arrays, and added each marker with a diff --git a/src/components/SpotLocationPicker.tsx b/src/components/SpotLocationPicker.tsx new file mode 100644 index 0000000..93aac30 --- /dev/null +++ b/src/components/SpotLocationPicker.tsx @@ -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(null); + const mapRef = useRef(null); + const markerRef = useRef(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: + '
' + + '
' + + '
' + + '
', + 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 ( +
+ ); +} diff --git a/src/hooks/useFormDraft.ts b/src/hooks/useFormDraft.ts new file mode 100644 index 0000000..682ba22 --- /dev/null +++ b/src/hooks/useFormDraft.ts @@ -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 `` (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( + prefix: string, + key: string, + snapshot: T, + apply: (draft: T) => void, +) { + const storageKey = prefix + key; + const [loaded, setLoaded] = useState(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 }; +} diff --git a/src/hooks/useLabDraft.ts b/src/hooks/useLabDraft.ts index 28d99d1..8227eb5 100644 --- a/src/hooks/useLabDraft.ts +++ b/src/hooks/useLabDraft.ts @@ -1,62 +1,11 @@ -import { useEffect, useRef, useState } from 'react'; +import { useFormDraft } from './useFormDraft'; const PREFIX = 'skatespots:lab-draft:'; /** - * Autosave a Lab profile form so a long create/edit isn't lost on nav or reload. - * - * - 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. + * 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. */ export function useLabDraft(key: string, snapshot: T, apply: (draft: T) => void) { - const storageKey = PREFIX + key; - const [loaded, setLoaded] = useState(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 }; + return useFormDraft(PREFIX, key, snapshot, apply); } diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 6ab2786..699918f 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -502,6 +502,18 @@ nav: { useCurrentLocation: 'Use my current location', pickOnMap: 'Pick Location on Map', 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.', coordinates: 'Coordinates', latitudePlaceholder: 'Latitude (e.g., 34.1012)', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 60abdb7..327cf80 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -502,6 +502,18 @@ nav: { useCurrentLocation: 'Usar mi ubicación actual', pickOnMap: 'Elegir Ubicación en el Mapa', 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.', coordinates: 'Coordenadas', latitudePlaceholder: 'Latitud (ej., 34.1012)', diff --git a/src/i18n/th/batch5.ts b/src/i18n/th/batch5.ts index e1f22c4..4c5f5d7 100644 --- a/src/i18n/th/batch5.ts +++ b/src/i18n/th/batch5.ts @@ -359,6 +359,22 @@ const thBatch5 = { publishError: 'เผยแพร่อันนั้นไม่สำเร็จ: {{error}} ลองอีกครั้งในอีกสักครู่', 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; diff --git a/src/pages/CreateSpotPage.tsx b/src/pages/CreateSpotPage.tsx index 267aef4..9bbe2cd 100644 --- a/src/pages/CreateSpotPage.tsx +++ b/src/pages/CreateSpotPage.tsx @@ -7,10 +7,12 @@ 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'; @@ -19,6 +21,7 @@ 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 } from '@/lib/geocodeRegions'; import { spotCategories, MAX_TAGS, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } from '@/lib/skateTags'; @@ -31,15 +34,43 @@ import { 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; + 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() { const { t } = useTranslation(); - + useSeoMeta({ title: `${t('createSpot.title')} - SkateSpots`, description: t('createSpot.subtitle'), @@ -64,6 +95,9 @@ export default function CreateSpotPage() { const [shelter, setShelter] = useState(DEFAULT_SHELTER); const [contactInfo, setContactInfo] = useState(''); const [images, setImages] = useState([]); + const [errors, setErrors] = useState({}); + 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. @@ -76,15 +110,51 @@ export default function CreateSpotPage() { // on this form. 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( + 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 lat = searchParams.get('lat'); - const lng = searchParams.get('lng'); - if (lat && lng) { - setLatitude(lat); - setLongitude(lng); + 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') { @@ -130,6 +200,7 @@ export default function CreateSpotPage() { const handleCategoryChange = (category: string) => { setSelectedCategory(category); + clearError('type'); }; const getCurrentLocation = () => { @@ -148,6 +219,7 @@ export default function CreateSpotPage() { 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'), @@ -179,6 +251,7 @@ export default function CreateSpotPage() { } } if (added > 0) { + clearError('photo'); toast({ title: added > 1 ? t('spotUi.photosUploaded', { count: added }) : t('spotUi.imageUploaded'), description: @@ -202,6 +275,7 @@ export default function CreateSpotPage() { if (url) { setImages((prev) => [...prev, url]); setShowCamera(false); + clearError('photo'); toast({ title: t('spotUi.photoCaptured'), description: t('spotUi.photoCapturedDesc'), @@ -223,44 +297,21 @@ export default function CreateSpotPage() { 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', - }); + // 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 (!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. @@ -272,19 +323,6 @@ export default function CreateSpotPage() { 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 ?? []) @@ -368,7 +406,7 @@ export default function CreateSpotPage() { // 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]); @@ -379,7 +417,7 @@ export default function CreateSpotPage() { // 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() })]); @@ -389,11 +427,11 @@ export default function CreateSpotPage() { tags: eventTags, }); + draft.clear(); + toast({ title: t('spotUi.spotAdded'), - description: images.length === 0 - ? t('spotUi.spotAddedNoPhoto') - : t('spotUi.spotAddedDesc'), + description: t('spotUi.spotAddedDesc'), }); // Navigate to spot detail using pubkey and identifier @@ -443,6 +481,32 @@ export default function CreateSpotPage() {

+ {/* Unsaved-draft banner */} + {draft.resumable && ( +
+ +
+

{t('createSpot.draftTitle')}

+

{t('createSpot.draftBody')}

+
+ + +
+ )} +
@@ -452,188 +516,93 @@ export default function CreateSpotPage() { - {/* Title */} + {/* ── Location (map picker) ─────────────────────────────── */} +
+ + +
+ + {t('createSpot.tapToPlace')} +
+ {errors.location && ( +

{errors.location}

+ )} + + + + + + +
+ { setLatitude(e.target.value); clearError('location'); }} + inputMode="decimal" + /> + { setLongitude(e.target.value); clearError('location'); }} + inputMode="decimal" + /> +
+
+
+
+ + {/* ── Title ─────────────────────────────────────────────── */}
setTitle(e.target.value)} - required + onChange={(e) => { setTitle(e.target.value); clearError('title'); }} + className={cn(errors.title && 'border-red-500/60')} /> + {errors.title &&

{errors.title}

}
- {/* Description */} + {/* ── Description ───────────────────────────────────────── */}
- +