From f1aa6400d5e52f9a1d58df7b63518e909b174a4a Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Mon, 2 Nov 2026 14:11:58 +0700 Subject: [PATCH] feat: streamline photo-first spot creation and location privacy --- AGENTS.md | 31 + src/components/CameraCapture.tsx | 3 +- src/components/Layout.tsx | 9 +- src/components/PhotoDropzone.tsx | 18 +- src/components/SpotLocationPicker.tsx | 14 +- src/components/auth/AccountSwitcher.tsx | 7 +- src/hooks/useFormDraft.ts | 16 +- src/hooks/useLoginActions.ts | 6 + src/i18n/ca/batch1.ts | 2 + src/i18n/cs/batch2.ts | 2 + src/i18n/de/batch2.ts | 2 + src/i18n/el/batch2.ts | 2 + src/i18n/en.ts | 1 + src/i18n/es.ts | 1 + src/i18n/eu/batch1.ts | 2 + src/i18n/fr/batch2.ts | 2 + src/i18n/gl/batch1.ts | 2 + src/i18n/hi/batch1.ts | 2 + src/i18n/id/batch2.ts | 2 + src/i18n/it/batch2.ts | 2 + src/i18n/ja/batch3.ts | 2 + src/i18n/ko/batch2.ts | 2 + src/i18n/nl/batch2.ts | 2 + src/i18n/pl/batch2.ts | 2 + src/i18n/pt-PT/drift.ts | 2 + src/i18n/pt/batch2.ts | 2 + src/i18n/ro/batch2.ts | 2 + src/i18n/ru/batch2.ts | 2 + src/i18n/sv/batch2.ts | 2 + src/i18n/th/batch3.ts | 2 + src/i18n/tr/batch2.ts | 2 + src/i18n/uk/batch2.ts | 2 + src/i18n/vi/batch2.ts | 2 + src/i18n/zh-CN/batch2.ts | 2 + src/i18n/zh-TW/batch2.ts | 2 + src/lib/lastLocation.ts | 71 +- src/pages/CreateSpotPage.tsx | 1218 +++++++++++------------ src/pages/MapPage.tsx | 68 +- src/pages/SettingsPage.tsx | 24 + 39 files changed, 870 insertions(+), 667 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d3edbc0..91c119a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1262,6 +1262,37 @@ session with **no other memory of this project**. So: "optional". Access keeps its **Public default** (so it is always satisfied; the `*` marks membership in the required set), and Address has real inline validation (`createSpot.addressRequired`). + **Photo-first privacy/low-friction pass (2026-11-02):** `CreateSpotPage` was + rebuilt around the phone path. The live GPS is **not** auto-pinned: the form + starts from the URL pin, then the last map view (`getLastMapView`), and only + falls back to the cached map centre; “Use my current location” is an explicit + action that can become the public pin. The map picker is collapsed to a pin + summary until “Edit”; photos are first (gallery-first, camera is a secondary + link); the note is a 2-row field; category is a horizontal chip row; access is + a compact 3-button row; address is optional in **More details** and is filled + from the reverse-geocode area on publish (this **supersedes the 2026-10-07 + required-address rule**); `QualityMeter` was removed; the publish CTA is fixed + above the mobile nav. Nearby duplicates show a non-blocking card with a + **Contribute photo** shortcut when photos are already selected. After publish + the app saves the new map view and returns to `/map` (toast action: Add another) + instead of forcing the detail page. + ⚠️ **Location privacy invariant:** Near Me GPS stays on-device and is never + auto-published. The explicit “Use my current location” action sets the public + spot pin, which is intentionally public; the warning key is + `spotUi.exactPinPublic` (translated in all 24 dictionaries). `clearLocationHistory()` + removes both `skatespots:last-location` and `skatespots:map-view` on + account switch/logout and is exposed in Settings → Appearance; drafts are + scoped by pubkey and `useFormDraft` debounces localStorage writes (400 ms). + ⚠️ **Remaining parity gap:** `EditSpotPage` still uses raw lat/lng + toast + validation and does not yet share the map picker/draft/duplicate guard. +- **Map Add entry points (2026-11-02):** every map Add Spot link now carries the + current `mapCenter` (`/create-spot?lat=&lng=`), including the desktop toolbar, + mobile speed-dial, and empty-map/list CTAs. On phones the top Add Spot/More + controls are hidden; the single floating Add speed-dial is the add surface. + It closes on map tap and is hidden while a `pendingAddPin` card is present. + `MapPage` persists the map view through `saveLastMapView` (with `at`) and + restores it through `getLastMapView`. + - **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/CameraCapture.tsx b/src/components/CameraCapture.tsx index 9ccdf95..fd09e40 100644 --- a/src/components/CameraCapture.tsx +++ b/src/components/CameraCapture.tsx @@ -261,7 +261,7 @@ export function CameraCapture({ - Take a Photo + {t('spotUi.takePhoto')} @@ -304,7 +304,6 @@ export function CameraCapture({ { const file = e.target.files?.[0]; diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 888887c..9625356 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -18,6 +18,7 @@ import { useSasquatchGuide } from '@/hooks/useSasquatchGuide'; import { LanguageToggle } from '@/components/LanguageToggle'; import { LANGUAGE_OPTIONS } from '@/lib/languageOptions'; import { clearTranslationMemory } from '@/lib/translation'; +import { clearLocationHistory } from '@/lib/lastLocation'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useTranslation } from '@/contexts/language'; import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts'; @@ -306,7 +307,10 @@ function MobileHeaderUserMenu({ return ( setLogin(account.id)} + onClick={() => { + clearLocationHistory(); + setLogin(account.id); + }} className="flex items-center gap-2 cursor-pointer" > @@ -892,7 +896,7 @@ export function Layout({ children, noPadding }: LayoutProps) { {user && ( { clearTranslationMemory(); removeLogin(currentAccount?.id || ''); }} + onLogout={() => { clearTranslationMemory(); clearLocationHistory(); removeLogin(currentAccount?.id || ''); }} onSetStatus={() => setShowStatusDialog(true)} onAddAccount={() => setShowAddAccount(true)} /> @@ -1156,6 +1160,7 @@ export function Layout({ children, noPadding }: LayoutProps) { className="w-full justify-center gap-3 text-red-400 border-red-500/50 hover:bg-red-500/20 hover:text-red-300 hover:border-red-500 h-12 text-base font-bold" onClick={() => { clearTranslationMemory(); + clearLocationHistory(); removeLogin(currentAccount?.id || ''); setMobileMenuOpen(false); }} diff --git a/src/components/PhotoDropzone.tsx b/src/components/PhotoDropzone.tsx index d5aec2d..2cdeb1c 100644 --- a/src/components/PhotoDropzone.tsx +++ b/src/components/PhotoDropzone.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Loader2, Upload } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useTranslation } from '@/contexts/language'; @@ -43,9 +43,23 @@ export function PhotoDropzone({ }: PhotoDropzoneProps) { const { t } = useTranslation(); const [dragging, setDragging] = useState(false); + const [touchDevice, setTouchDevice] = useState(false); + + useEffect(() => { + try { + setTouchDevice( + window.matchMedia('(pointer: coarse)').matches || + (typeof navigator !== 'undefined' && navigator.maxTouchPoints > 0) || + window.innerWidth < 640, + ); + } catch { + // matchMedia is unavailable in some test/embedded contexts + } + }, []); + const busy = uploading || disabled; const resolvedLabel = label ?? t('spotUi.addPhotos'); - const resolvedHint = hint ?? t('spotUi.dragDropOrClick'); + const resolvedHint = hint ?? (touchDevice ? t('miscUi.uploadFromGallery') : t('spotUi.dragDropOrClick')); const emit = (fileList: FileList | null) => { if (!fileList) return; diff --git a/src/components/SpotLocationPicker.tsx b/src/components/SpotLocationPicker.tsx index 62c719d..13daac3 100644 --- a/src/components/SpotLocationPicker.tsx +++ b/src/components/SpotLocationPicker.tsx @@ -19,6 +19,9 @@ interface SpotLocationPickerProps { value: LatLng | null; onChange: (coords: LatLng) => void; className?: string; + /** Start here when there is no pin yet (usually the last map view). */ + defaultCenter?: [number, number]; + defaultZoom?: number; } /** Fallback center before the user has a location (NYC, same as the map default). */ @@ -32,7 +35,7 @@ const DEFAULT_CENTER: [number, number] = [40.7128, -74.006]; * 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) { +export function SpotLocationPicker({ value, onChange, className, defaultCenter, defaultZoom }: SpotLocationPickerProps) { const containerRef = useRef(null); const mapRef = useRef(null); const markerRef = useRef(null); @@ -51,10 +54,12 @@ export function SpotLocationPicker({ value, onChange, className }: SpotLocationP if (!el || mapRef.current) return; const initial = initialValueRef.current; - const center: [number, number] = initial ? [initial.lat, initial.lng] : DEFAULT_CENTER; + const center: [number, number] = initial + ? [initial.lat, initial.lng] + : (defaultCenter ?? DEFAULT_CENTER); const map = L.map(el, { center, - zoom: initial ? 15 : 12, + zoom: initial ? 15 : (defaultZoom ?? (defaultCenter ? 14 : 12)), zoomControl: true, attributionControl: true, }); @@ -86,6 +91,9 @@ export function SpotLocationPicker({ value, onChange, className }: SpotLocationP mapRef.current = null; markerRef.current = null; }; + // The map is created once; defaultCenter/defaultZoom are only the initial + // seed. Later value changes are handled by the marker-sync effect below. + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Draw / move the draggable pin to match `value`. diff --git a/src/components/auth/AccountSwitcher.tsx b/src/components/auth/AccountSwitcher.tsx index 298f134..96b2f18 100644 --- a/src/components/auth/AccountSwitcher.tsx +++ b/src/components/auth/AccountSwitcher.tsx @@ -13,6 +13,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar.tsx' import { WalletModal } from '@/components/WalletModal'; import { clearDmDecryptCache } from '@/lib/dmDecryptCache'; import { clearTranslationMemory } from '@/lib/translation'; +import { clearLocationHistory } from '@/lib/lastLocation'; import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts'; import { genUserName } from '@/lib/genUserName'; import { useTranslation } from '@/contexts/language'; @@ -63,7 +64,10 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) { {otherUsers.map((user) => ( setLogin(user.id)} + onClick={() => { + clearLocationHistory(); + setLogin(user.id); + }} className='flex items-center gap-2 cursor-pointer p-2 rounded-md' > @@ -104,6 +108,7 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) { // another account on the same device can't read it from IndexedDB. void clearDmDecryptCache(currentUser.id).catch(() => {}); clearTranslationMemory(); + clearLocationHistory(); removeLogin(currentUser.id); }} className='flex items-center gap-2 cursor-pointer p-2 rounded-md text-red-500' diff --git a/src/hooks/useFormDraft.ts b/src/hooks/useFormDraft.ts index 682ba22..cbd2471 100644 --- a/src/hooks/useFormDraft.ts +++ b/src/hooks/useFormDraft.ts @@ -34,17 +34,21 @@ export function useFormDraft( } }, [storageKey]); - // Persist on change (skip the initial empty paint). + // Persist on change (skip the initial empty paint). Debounce so a phone + // doesn't synchronously write localStorage on every keystroke. 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 - } + const timer = window.setTimeout(() => { + try { + localStorage.setItem(storageKey, serialized); + } catch { + // storage may be full/blocked — a lost draft is not fatal + } + }, 400); + return () => window.clearTimeout(timer); }, [serialized, storageKey]); const resume = () => { diff --git a/src/hooks/useLoginActions.ts b/src/hooks/useLoginActions.ts index f8706b0..d2383db 100644 --- a/src/hooks/useLoginActions.ts +++ b/src/hooks/useLoginActions.ts @@ -4,6 +4,7 @@ import { type NLoginType, useNostrLogin, } from '@/lib/nostrLogin'; +import { clearLocationHistory } from '@/lib/lastLocation'; // NOTE: This file should not be edited except for adding new login methods. @@ -16,6 +17,9 @@ export function useLoginActions() { const { logins, addLogin, setLogin, removeLogin } = useNostrLogin(); const addAndActivate = (login: NLoginType) => { + // A different account on the same device must not inherit the previous + // account's last map area. + clearLocationHistory(); addLogin(login); setLogin(login.id); }; @@ -41,6 +45,7 @@ export function useLoginActions() { // and pubkey is correctly stored and rehydrated from localStorage. amber(pubkey: string): void { const login = new NLogin('x-amber', pubkey, { method: 'nip55' }) as unknown as NLoginType; + clearLocationHistory(); addLogin(login); setLogin(login.id); }, @@ -48,6 +53,7 @@ export function useLoginActions() { async logout(): Promise { const login = logins[0]; if (login) { + clearLocationHistory(); removeLogin(login.id); } }, diff --git a/src/i18n/ca/batch1.ts b/src/i18n/ca/batch1.ts index d715551..333a0f2 100644 --- a/src/i18n/ca/batch1.ts +++ b/src/i18n/ca/batch1.ts @@ -472,6 +472,8 @@ const batch1 = { "somethingWentWrong": "Hi ha hagut un error" }, "spotUi": { + exactPinPublic: 'La ubicació exacta és pública: no marquis casa teva.', + "claim": { "areYouOwner": "És el teu spot?", "privateDesc": "Aquest spot està marcat com a privat. Si és teu o el gestiones, reclama'l: un moderador de SkateSpots confirma la propietat i després passes a fer-te'n càrrec de la fitxa.", diff --git a/src/i18n/cs/batch2.ts b/src/i18n/cs/batch2.ts index 5cd3728..8a1906b 100644 --- a/src/i18n/cs/batch2.ts +++ b/src/i18n/cs/batch2.ts @@ -196,6 +196,8 @@ const csBatch2 = { addFirstSpot: "Přidat první spot", }, spotUi: { + exactPinPublic: 'Přesná poloha je veřejná – neoznačuj svůj domov.', + claim: { areYouOwner: "Je to tvůj spot?", privateDesc: "Tento spot je označen jako soukromý. Pokud ho vlastníš nebo spravuješ, nárokuj si ho — moderátor SkateSpots potvrdí vlastnictví a pak převezmeš záznam.", diff --git a/src/i18n/de/batch2.ts b/src/i18n/de/batch2.ts index 6398cef..556c46b 100644 --- a/src/i18n/de/batch2.ts +++ b/src/i18n/de/batch2.ts @@ -196,6 +196,8 @@ const deBatch2 = { addFirstSpot: "Ersten Spot hinzufügen", }, spotUi: { + exactPinPublic: 'Der genaue Pin ist öffentlich – markiere nicht dein Zuhause.', + claim: { areYouOwner: "Ist das dein Spot?", privateDesc: "Dieser Spot ist als privat markiert. Wenn er dir gehört oder du ihn verwaltest, fordere ihn an – ein SkateSpots-Moderator bestätigt das Eigentum, dann übernimmst du den Eintrag.", diff --git a/src/i18n/el/batch2.ts b/src/i18n/el/batch2.ts index c116d45..be26bd9 100644 --- a/src/i18n/el/batch2.ts +++ b/src/i18n/el/batch2.ts @@ -196,6 +196,8 @@ const elBatch2 = { addFirstSpot: "Πρόσθεσε το πρώτο spot", }, spotUi: { + exactPinPublic: 'Η ακριβής τοποθεσία είναι δημόσια — μην καρφιτσώνεις το σπίτι σου.', + claim: { areYouOwner: "Είναι αυτό το spot σου;", privateDesc: "Αυτό το spot έχει επισημανθεί ως ιδιωτικό. Αν σου ανήκει ή το διαχειρίζεσαι, διεκδίκησέ το — ένας moderator του SkateSpots επιβεβαιώνει την ιδιοκτησία και μετά αναλαμβάνεις την καταχώριση.", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 1acb60e..421aa79 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -1081,6 +1081,7 @@ mapUi: { }, spotUi: { + exactPinPublic: 'Exact pin is public — don\'t pin your home.', claim: { areYouOwner: 'Is this your spot?', privateDesc: 'This spot is marked private. If you own or manage it, claim it — a SkateSpots moderator confirms ownership, then you take over the listing.', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 945d754..58bba43 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -1081,6 +1081,7 @@ mapUi: { }, spotUi: { + exactPinPublic: 'La ubicación exacta es pública: no marques tu casa.', claim: { areYouOwner: '¿Es este tu spot?', privateDesc: 'Este spot está marcado como privado. Si es tuyo o lo gestionas, reclámalo: un moderador de SkateSpots confirma la propiedad y después asumes el anuncio.', diff --git a/src/i18n/eu/batch1.ts b/src/i18n/eu/batch1.ts index cfe176f..f23887d 100644 --- a/src/i18n/eu/batch1.ts +++ b/src/i18n/eu/batch1.ts @@ -472,6 +472,8 @@ const batch1 = { somethingWentWrong: "Zerbait gaizki joan da" }, spotUi: { + exactPinPublic: 'Kokapen zehatza publikoa da — ez markatu zure etxea.', + claim: { areYouOwner: "Hau zure tokia da?", privateDesc: "Toki hau pribatu gisa markatuta dago. Zurea edo kudeatzen duzuna bada, erreklamatu — SkateSpots moderatzaile batek jabetza egiaztatuko du, eta orduan zuk hartuko duzu zerrenda.", diff --git a/src/i18n/fr/batch2.ts b/src/i18n/fr/batch2.ts index 3834d36..4f4ddfa 100644 --- a/src/i18n/fr/batch2.ts +++ b/src/i18n/fr/batch2.ts @@ -196,6 +196,8 @@ const frBatch2 = { addFirstSpot: "Ajouter le premier spot", }, spotUi: { + exactPinPublic: 'L’emplacement exact est public — ne placez pas votre domicile.', + claim: { areYouOwner: "C'est votre spot ?", privateDesc: "Ce spot est marqué privé. Si vous le possédez ou le gérez, revendiquez-le — un modérateur SkateSpots confirme la propriété, puis vous reprenez l'annonce.", diff --git a/src/i18n/gl/batch1.ts b/src/i18n/gl/batch1.ts index ef5a61f..e97561f 100644 --- a/src/i18n/gl/batch1.ts +++ b/src/i18n/gl/batch1.ts @@ -472,6 +472,8 @@ const batch1 = { somethingWentWrong: "Algo saíu mal" }, spotUi: { + exactPinPublic: 'A localización exacta é pública: non marques a túa casa.', + claim: { areYouOwner: "É este o teu spot?", privateDesc: "Este spot está marcado como privado. Se é teu ou o xestionas, reclámao: un moderador de SkateSpots confirma a propiedade e despois pasas a xestionar a ficha.", diff --git a/src/i18n/hi/batch1.ts b/src/i18n/hi/batch1.ts index ac23d65..4d614e7 100644 --- a/src/i18n/hi/batch1.ts +++ b/src/i18n/hi/batch1.ts @@ -472,6 +472,8 @@ const batch1 = { "somethingWentWrong": "कुछ गलत हो गया" }, "spotUi": { + exactPinPublic: 'सटीक पिन सार्वजनिक है — अपना घर पिन न करें।', + "claim": { "areYouOwner": "क्या यह आपका स्पॉट है?", "privateDesc": "यह स्पॉट निजी के रूप में चिह्नित है। अगर यह आपका है या आप इसे प्रबंधित करते हैं, तो इसे क्लेम करें — SkateSpots मॉडरेटर स्वामित्व की पुष्टि करता है, फिर आप लिस्टिंग संभालते हैं।", diff --git a/src/i18n/id/batch2.ts b/src/i18n/id/batch2.ts index 2913030..bf89582 100644 --- a/src/i18n/id/batch2.ts +++ b/src/i18n/id/batch2.ts @@ -196,6 +196,8 @@ const idBatch2 = { addFirstSpot: "Tambah Spot Pertama", }, spotUi: { + exactPinPublic: 'Pin akurat ini publik — jangan tandai rumahmu.', + claim: { areYouOwner: "Apakah ini spot Anda?", privateDesc: "Spot ini ditandai privat. Jika Anda memiliki atau mengelolanya, klaim — moderator SkateSpots mengonfirmasi kepemilikan, lalu Anda mengambil alih listing.", diff --git a/src/i18n/it/batch2.ts b/src/i18n/it/batch2.ts index 80972ef..94b97f1 100644 --- a/src/i18n/it/batch2.ts +++ b/src/i18n/it/batch2.ts @@ -196,6 +196,8 @@ const itBatch2 = { addFirstSpot: "Aggiungi il primo spot", }, spotUi: { + exactPinPublic: 'La posizione esatta è pubblica: non segnare casa tua.', + claim: { areYouOwner: "È il tuo spot?", privateDesc: "Questo spot è contrassegnato come privato. Se lo possiedi o lo gestisci, rivendicalo — un moderatore di SkateSpots conferma la proprietà, poi subentri nell'inserzione.", diff --git a/src/i18n/ja/batch3.ts b/src/i18n/ja/batch3.ts index 5490d39..8bf435e 100644 --- a/src/i18n/ja/batch3.ts +++ b/src/i18n/ja/batch3.ts @@ -5,6 +5,8 @@ */ const jaBatch3 = { spotUi: { + exactPinPublic: '正確なピンは公開されます。自宅はピン留めしないでください。', + claim: { areYouOwner: 'ここはあなたのスポットですか?', privateDesc: 'このスポットは非公開に設定されています。所有者または管理者なら申請してください — SkateSpotsのモデレーターが所有権を確認したうえで、あなたが管理を引き継ぎます。', diff --git a/src/i18n/ko/batch2.ts b/src/i18n/ko/batch2.ts index bb18dd0..ae2e51b 100644 --- a/src/i18n/ko/batch2.ts +++ b/src/i18n/ko/batch2.ts @@ -201,6 +201,8 @@ const koBatch2 = { addFirstSpot: '첫 스팟 추가', }, spotUi: { + exactPinPublic: '정확한 핀은 공개됩니다. 집 위치는 찍지 마세요.', + claim: { areYouOwner: '이 스팟이 당신 것인가요?', privateDesc: '이 스팟은 비공개로 표시되어 있습니다. 소유자나 관리자라면 소유권을 주장하세요 — SkateSpots 중재자가 소유권을 확인한 뒤 목록을 넘겨받게 됩니다.', diff --git a/src/i18n/nl/batch2.ts b/src/i18n/nl/batch2.ts index e471c0c..3f005bf 100644 --- a/src/i18n/nl/batch2.ts +++ b/src/i18n/nl/batch2.ts @@ -196,6 +196,8 @@ const nlBatch2 = { addFirstSpot: "Eerste spot toevoegen", }, spotUi: { + exactPinPublic: 'De exacte pin is openbaar — pin niet je huis.', + claim: { areYouOwner: "Is dit jouw spot?", privateDesc: "Deze spot is als privé gemarkeerd. Als je de eigenaar bent of hem beheert, claim hem — een SkateSpots-moderator bevestigt het eigenaarschap, waarna jij de listing overneemt.", diff --git a/src/i18n/pl/batch2.ts b/src/i18n/pl/batch2.ts index e8dc0b1..763c2a3 100644 --- a/src/i18n/pl/batch2.ts +++ b/src/i18n/pl/batch2.ts @@ -196,6 +196,8 @@ const plBatch2 = { addFirstSpot: "Dodaj pierwszy Spot", }, spotUi: { + exactPinPublic: 'Dokładna lokalizacja jest publiczna — nie oznaczaj swojego domu.', + claim: { areYouOwner: "Czy to twój Spot?", privateDesc: "Ten Spot jest oznaczony jako prywatny. Jeśli jesteś jego właścicielem lub zarządzasz nim, przejmij go — moderator SkateSpots potwierdza własność, a następnie przejmujesz wpis.", diff --git a/src/i18n/pt-PT/drift.ts b/src/i18n/pt-PT/drift.ts index 25e7cf7..d8f0f46 100644 --- a/src/i18n/pt-PT/drift.ts +++ b/src/i18n/pt-PT/drift.ts @@ -23,6 +23,8 @@ const drift = { }, }, spotUi: { + exactPinPublic: 'A localização exata é pública — não marque a sua casa.', + saveChanges: "Guardar alterações", targetType: { user: "Utilizador", diff --git a/src/i18n/pt/batch2.ts b/src/i18n/pt/batch2.ts index 64e1f6f..d2da9b8 100644 --- a/src/i18n/pt/batch2.ts +++ b/src/i18n/pt/batch2.ts @@ -196,6 +196,8 @@ const ptBatch2 = { addFirstSpot: "Adicionar primeiro spot", }, spotUi: { + exactPinPublic: 'A localização exata é pública — não marque a sua casa.', + claim: { areYouOwner: "Este spot é seu?", privateDesc: "Este spot está marcado como privado. Se você é dono ou cuida dele, reivindique-o — um moderador do SkateSpots confirma a propriedade e então você assume o anúncio.", diff --git a/src/i18n/ro/batch2.ts b/src/i18n/ro/batch2.ts index badab52..26f79d5 100644 --- a/src/i18n/ro/batch2.ts +++ b/src/i18n/ro/batch2.ts @@ -196,6 +196,8 @@ const roBatch2 = { addFirstSpot: "Adaugă primul spot", }, spotUi: { + exactPinPublic: 'Locația exactă este publică — nu marca locuința ta.', + claim: { areYouOwner: "Acesta este spotul tău?", privateDesc: "Acest spot este marcat privat. Dacă îl deții sau îl administrezi, revendică-l — un moderator SkateSpots confirmă proprietatea, apoi preiei anunțul.", diff --git a/src/i18n/ru/batch2.ts b/src/i18n/ru/batch2.ts index 87876aa..48ddac9 100644 --- a/src/i18n/ru/batch2.ts +++ b/src/i18n/ru/batch2.ts @@ -196,6 +196,8 @@ const ruBatch2 = { "addFirstSpot": "Добавить первый спот" }, "spotUi": { + exactPinPublic: 'Точная метка видна всем — не отмечайте свой дом.', + "claim": { "areYouOwner": "Это ваш спот?", "privateDesc": "Этот спот отмечен как приватный. Если он принадлежит вам или вы им управляете, заявите права — модератор SkateSpots подтвердит владение, и вы возьмёте объявление под контроль.", diff --git a/src/i18n/sv/batch2.ts b/src/i18n/sv/batch2.ts index 315be7f..2deccbc 100644 --- a/src/i18n/sv/batch2.ts +++ b/src/i18n/sv/batch2.ts @@ -202,6 +202,8 @@ const svBatch2 = { addFirstSpot: 'Lägg till första spotten', }, spotUi: { + exactPinPublic: 'Den exakta kartnålen är offentlig – markera inte ditt hem.', + claim: { areYouOwner: 'Är det här din spot?', privateDesc: 'Den här spotten är markerad som privat. Om du äger eller sköter den, gör anspråk på den — en SkateSpots-moderator bekräftar ägandet, sedan tar du över listningen.', diff --git a/src/i18n/th/batch3.ts b/src/i18n/th/batch3.ts index 0a63d76..19c5b10 100644 --- a/src/i18n/th/batch3.ts +++ b/src/i18n/th/batch3.ts @@ -6,6 +6,8 @@ */ const thBatch3 = { spotUi: { + exactPinPublic: 'ตำแหน่งหมุดที่แม่นยำเป็นข้อมูลสาธารณะ — อย่าปักหมุดบ้านของคุณ', + claim: { areYouOwner: 'นี่คือสปอตของคุณหรือไม่?', privateDesc: 'สปอตนี้ถูกทำเครื่องหมายเป็นส่วนตัว ถ้าคุณเป็นเจ้าของหรือดูแลอยู่ ให้อ้างสิทธิ์ — ผู้ดูแล SkateSpots จะยืนยันความเป็นเจ้าของ แล้วคุณจึงเข้าควบคุมรายการได้', diff --git a/src/i18n/tr/batch2.ts b/src/i18n/tr/batch2.ts index 745a4e3..05a9e3d 100644 --- a/src/i18n/tr/batch2.ts +++ b/src/i18n/tr/batch2.ts @@ -196,6 +196,8 @@ const trBatch2: Record = { addFirstSpot: "İlk Spotu Ekle", }, spotUi: { + exactPinPublic: 'Tam konum herkese açıktır — evinizi işaretlemeyin.', + claim: { areYouOwner: "Burası senin spotun mu?", privateDesc: "Bu spot özel olarak işaretlenmiş. Sahibiysen veya yönetiyorsan, sahiplen — bir SkateSpots moderatörü sahipliği doğrular, sonra ilanı devralırsın.", diff --git a/src/i18n/uk/batch2.ts b/src/i18n/uk/batch2.ts index b38f90f..166ebb7 100644 --- a/src/i18n/uk/batch2.ts +++ b/src/i18n/uk/batch2.ts @@ -196,6 +196,8 @@ const ukBatch2 = { addFirstSpot: "Додати перший Spot" }, spotUi: { + exactPinPublic: 'Точна позначка є публічною — не позначайте свій дім.', + claim: { areYouOwner: "Це ваш Spot?", privateDesc: "Цей Spot позначено як приватний. Якщо він належить вам або ви ним керуєте, заявіть на нього — модератор SkateSpots підтвердить право власності, і ви візьмете оголошення під контроль.", diff --git a/src/i18n/vi/batch2.ts b/src/i18n/vi/batch2.ts index e9f4ecb..b71470a 100644 --- a/src/i18n/vi/batch2.ts +++ b/src/i18n/vi/batch2.ts @@ -196,6 +196,8 @@ const viBatch2 = { addFirstSpot: 'Thêm spot đầu tiên', }, spotUi: { + exactPinPublic: 'Vị trí chính xác là công khai — đừng ghim nhà của bạn.', + claim: { areYouOwner: 'Đây có phải spot của bạn?', privateDesc: 'Spot này được đánh dấu riêng tư. Nếu bạn sở hữu hoặc quản lý nó, hãy nhận — một kiểm duyệt viên SkateSpots xác nhận quyền sở hữu, sau đó bạn tiếp quản danh sách.', diff --git a/src/i18n/zh-CN/batch2.ts b/src/i18n/zh-CN/batch2.ts index 4b8cb6b..a94f61e 100644 --- a/src/i18n/zh-CN/batch2.ts +++ b/src/i18n/zh-CN/batch2.ts @@ -202,6 +202,8 @@ const zhCNBatch2 = { addFirstSpot: "添加第一个 Spot", }, spotUi: { + exactPinPublic: '精确位置会公开 — 请不要标记自己的家。', + claim: { areYouOwner: "这是你的 Spot 吗?", privateDesc: "此 Spot 被标记为私人。如果你拥有或管理它,请认领——SkateSpots 版主确认所有权后,你即可接管该条目。", diff --git a/src/i18n/zh-TW/batch2.ts b/src/i18n/zh-TW/batch2.ts index 8747d6f..ba7b8cc 100644 --- a/src/i18n/zh-TW/batch2.ts +++ b/src/i18n/zh-TW/batch2.ts @@ -202,6 +202,8 @@ const zhTWBatch2 = { addFirstSpot: "新增第一個 Spot", }, spotUi: { + exactPinPublic: '精確位置會公開 — 請不要標記自己的家。', + claim: { areYouOwner: "這是你的 Spot 嗎?", privateDesc: "此 Spot 被標記為私人。如果你擁有或管理它,請認領——SkateSpots 版主確認所有權後,你即可接管該條目。", diff --git a/src/lib/lastLocation.ts b/src/lib/lastLocation.ts index f9df434..fa94564 100644 --- a/src/lib/lastLocation.ts +++ b/src/lib/lastLocation.ts @@ -8,8 +8,9 @@ * resolves in the background. * * This is a coarse convenience cache, not a source of truth — a fresh fix always - * supersedes it. We never store accuracy or timestamps beyond what's needed and - * it lives only in this browser/app. + * supersedes it. It also stores the last map view so the Add Spot form can start + * near the place the user was already looking at. Both are local-only and are + * cleared on account switch/logout. */ export interface LastLocation { @@ -20,10 +21,18 @@ export interface LastLocation { } const KEY = 'skatespots:last-location'; +const MAP_VIEW_KEY = 'skatespots:map-view'; /** Consider a cached location "usable for centering" for 30 days. */ const MAX_AGE = 30 * 24 * 60 * 60 * 1000; +export interface LastMapView { + lat: number; + lon: number; + zoom: number; + at: number; +} + /** Persist a successful location fix. Never throws. */ export function saveLastLocation(lat: number, lng: number): void { try { @@ -65,3 +74,61 @@ export function getLastLocation(): LastLocation | null { return null; } } + +/** Persist the last map view locally so the form can start near where the user was looking. */ +export function saveLastMapView(lat: number, lon: number, zoom: number): void { + try { + if ( + !Number.isFinite(lat) || + !Number.isFinite(lon) || + !Number.isFinite(zoom) || + (lat === 0 && lon === 0) + ) { + return; + } + const payload: LastMapView = { lat, lon, zoom, at: Date.now() }; + localStorage.setItem(MAP_VIEW_KEY, JSON.stringify(payload)); + } catch { + // ignore private-mode / quota failures + } +} + +/** + * Read the last map view. This is only a local starting point for the Add Spot + * form (and the map); it is never published as a location. + */ +export function getLastMapView(): LastMapView | null { + try { + const raw = localStorage.getItem(MAP_VIEW_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed?.lat !== 'number' || + typeof parsed?.lon !== 'number' || + typeof parsed?.zoom !== 'number' || + typeof parsed?.at !== 'number' || + !Number.isFinite(parsed.lat) || + !Number.isFinite(parsed.lon) || + !Number.isFinite(parsed.zoom) + ) { + return null; + } + if (Date.now() - parsed.at > MAX_AGE) return null; + return { lat: parsed.lat, lon: parsed.lon, zoom: parsed.zoom, at: parsed.at }; + } catch { + return null; + } +} + +/** + * Clear the local location history. Call this on account switch/logout so the + * next account on a shared device cannot inherit the previous person's area. + */ +export function clearLocationHistory(): void { + try { + localStorage.removeItem(KEY); + localStorage.removeItem(MAP_VIEW_KEY); + } catch { + // ignore private-mode / quota failures + } +} diff --git a/src/pages/CreateSpotPage.tsx b/src/pages/CreateSpotPage.tsx index c7346a4..92bb610 100644 --- a/src/pages/CreateSpotPage.tsx +++ b/src/pages/CreateSpotPage.tsx @@ -1,13 +1,15 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useSeoMeta } from '@unhead/react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import ngeohash from 'ngeohash'; +import { useQueryClient } from '@tanstack/react-query'; 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 { ToastAction } from '@/components/ui/toast'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useUploadFile } from '@/hooks/useUploadFile'; @@ -18,14 +20,16 @@ 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 { SPOT_TAG_CATEGORY_KEYS, SHELTER_KEYS, SHELTER_DESC_KEYS, ACCESS_TYPE_KEYS } from '@/lib/labelKeys'; +import { getCurrentPosition } from '@/lib/geolocation'; +import { getLastLocation, getLastMapView, saveLastMapView } from '@/lib/lastLocation'; +import { PHOTO_CONTRIBUTION_KIND } from '@/hooks/useSpotContributions'; import { cn } from '@/lib/utils'; import { MapPin, @@ -45,7 +49,6 @@ import { Link } from 'react-router-dom'; type FieldErrors = { title?: string; description?: string; - address?: string; location?: string; type?: string; photo?: string; @@ -72,6 +75,24 @@ const DRAFT_PREFIX = 'skatespots:spot-draft:'; /** How long to wait for a reverse-geocode before publishing without region tags. */ const GEOCODE_BUDGET_MS = 2500; +function mimeForImageUrl(url: string): string { + const match = url.match(/\.(png|webp|gif|heic|heif|jpe?g)(?:[?#]|$)/i); + if (!match) return 'image/jpeg'; + const ext = match[1].toLowerCase(); + if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg'; + if (ext === 'heic' || ext === 'heif') return 'image/heic'; + return `image/${ext}`; +} + +function validCoords(latParam: string | null, lngParam: string | null): LatLng | null { + if (!latParam || !lngParam) return null; + const lat = parseFloat(latParam); + const lng = parseFloat(lngParam); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null; + if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null; + return { lat, lng }; +} + export default function CreateSpotPage() { const { t } = useTranslation(); @@ -82,17 +103,30 @@ export default function CreateSpotPage() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); + const queryClient = useQueryClient(); const { user } = useCurrentUser(); const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const { toast } = useToast(); + // Start from the place the user was already looking at. A live GPS fix is + // deliberately NOT used as an automatic pin: the pin becomes public. + const initial = useMemo(() => { + const urlPin = validCoords(searchParams.get('lat'), searchParams.get('lng')); + if (urlPin) return { pin: urlPin, source: 'map' as const }; + const savedView = getLastMapView(); + if (savedView) { + return { pin: { lat: savedView.lat, lng: savedView.lon }, source: 'map' as const }; + } + return { pin: null, source: null }; + }, [searchParams]); + // Form state const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); const [location, setLocation] = useState(''); - const [latitude, setLatitude] = useState(''); - const [longitude, setLongitude] = useState(''); + const [latitude, setLatitude] = useState(initial.pin ? initial.pin.lat.toFixed(6) : ''); + const [longitude, setLongitude] = useState(initial.pin ? initial.pin.lng.toFixed(6) : ''); const [selectedCategory, setSelectedCategory] = useState(''); const [tags, setTags] = useState([]); const [accessType, setAccessType] = useState<'public' | 'private' | 'locals'>('public'); @@ -101,18 +135,16 @@ export default function CreateSpotPage() { const [images, setImages] = useState([]); const [errors, setErrors] = useState({}); const [showMore, setShowMore] = useState(false); + const [showLocationMap, setShowLocationMap] = useState(!initial.pin); 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. + // one. This is the cheapest fix for clone spam and it points the contributor + // at the existing pin instead. const { data: existingSpots } = useSkateSpots(2000); const [dupWarning, setDupWarning] = useState | 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); @@ -131,9 +163,9 @@ export default function CreateSpotPage() { (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) }; + const { latitude: spotLat, longitude: spotLng } = ngeohash.decode(s.geohash); + if (!Number.isFinite(spotLat) || !Number.isFinite(spotLng)) return null; + return { spot: s, km: calculateDistance(la, ln, spotLat, spotLng) }; }) .filter((x): x is { spot: SkateSpot; km: number } => !!x && x.km < 0.06) .sort((a, b) => a.km - b.km) @@ -166,10 +198,23 @@ export default function CreateSpotPage() { clearError('location'); }; - // Autosave the draft so a long form isn't lost on a back-navigation. + // Pre-fill coordinates from URL params (from map tap / quick capture). + useEffect(() => { + const pin = validCoords(searchParams.get('lat'), searchParams.get('lng')); + if (pin) { + setLatitude(pin.lat.toFixed(6)); + setLongitude(pin.lng.toFixed(6)); + setShowLocationMap(false); + clearError('location'); + } + }, [searchParams]); + + // Autosave the draft so a long form isn't lost on a back-navigation. Scope it + // to the account so switching accounts on a shared device can't reveal one + // person's unfinished spot to another. const draft = useFormDraft( DRAFT_PREFIX, - 'new', + user?.pubkey ?? 'anon', { title, description, location, latitude, longitude, selectedCategory, tags, accessType, shelter, contactInfo, images }, (d) => { setTitle(d.title ?? ''); @@ -183,97 +228,36 @@ export default function CreateSpotPage() { setShelter(d.shelter ?? DEFAULT_SHELTER); setContactInfo(d.contactInfo ?? ''); setImages(Array.isArray(d.images) ? d.images : []); + if (d.latitude && d.longitude) setShowLocationMap(false); }, ); - // 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) { + const getCurrentLocation = async () => { + setIsGettingLocation(true); + try { + const position = await getCurrentPosition(); + setLatitude(position.lat.toFixed(6)); + setLongitude(position.lng.toFixed(6)); + setShowLocationMap(false); + clearError('location'); toast({ - title: t('spotUi.geolocationNotSupported'), - description: t('spotUi.geolocationNotSupportedDesc'), + title: t('spotUi.locationFound'), + description: t('spotUi.locationFoundDesc'), + }); + } catch (error) { + toast({ + title: t('spotUi.locationError'), + description: error instanceof Error ? error.message : t('spotUi.locationRequiredDesc'), variant: 'destructive', }); - return; + } finally { + setIsGettingLocation(false); } - - 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 @@ -283,8 +267,8 @@ export default function CreateSpotPage() { let added = 0; try { for (const file of files) { - const tags = await uploadFile(file); - const url = tags[0]?.[1]; + const photoTags = await uploadFile(file); + const url = photoTags[0]?.[1]; if (url) { setImages((prev) => (prev.includes(url) ? prev : [...prev, url])); added += 1; @@ -294,8 +278,7 @@ export default function CreateSpotPage() { clearError('photo'); toast({ title: added > 1 ? t('spotUi.photosUploaded', { count: added }) : t('spotUi.imageUploaded'), - description: - added > 1 ? t('spotUi.photosAdded') : t('spotUi.imageUploadedDesc'), + description: added > 1 ? t('spotUi.photosAdded') : t('spotUi.imageUploadedDesc'), }); } } catch (error) { @@ -310,10 +293,10 @@ export default function CreateSpotPage() { // Handle camera capture const handleCameraCapture = async (file: File) => { try { - const tags = await uploadFile(file); - const url = tags[0]?.[1]; + const photoTags = await uploadFile(file); + const url = photoTags[0]?.[1]; if (url) { - setImages((prev) => [...prev, url]); + setImages((prev) => (prev.includes(url) ? prev : [...prev, url])); setShowCamera(false); clearError('photo'); toast({ @@ -334,6 +317,70 @@ export default function CreateSpotPage() { setImages((prev) => prev.filter((img) => img !== url)); }; + // If the pin is already a known spot, a photo + note contribution is much + // lower friction than creating a duplicate. This uses the same kind as the + // spot detail page's Add Photo flow. + const handleContribute = async (target: SkateSpot) => { + if (images.length === 0) { + toast({ + title: t('createSpot.photoRequired'), + description: t('spotUi.uploadPhotoFirst'), + variant: 'destructive', + }); + return; + } + + try { + const spotRef = `${SKATE_SPOT_KIND}:${target.pubkey}:${target.identifier}`; + for (const imageUrl of images) { + await createEvent({ + kind: PHOTO_CONTRIBUTION_KIND, + content: description.trim(), + tags: [ + ['url', imageUrl], + ['a', spotRef], + ['m', mimeForImageUrl(imageUrl)], + ['alt', t('spotUi.altSkateSpot', { title: target.title })], + ], + }); + } + draft.clear(); + queryClient.invalidateQueries({ queryKey: ['spot-photos', target.pubkey, target.identifier] }); + toast({ + title: t('spotUi.photoContributed'), + description: t('spotUi.photoContributedDesc'), + }); + navigate(`/spot/${target.pubkey}/${target.identifier}`); + } catch (error) { + console.error('Failed to contribute to spot:', error); + toast({ + title: t('spotUi.failedToPublish'), + description: error instanceof Error ? error.message : t('spotUi.failedToPublishDesc'), + variant: 'destructive', + }); + } + }; + + const scrollToFirstError = (errs: FieldErrors) => { + const id = errs.location + ? 'spot-location' + : errs.photo + ? 'spot-photos' + : errs.title + ? 'spot-title' + : errs.description + ? 'spot-description' + : errs.type + ? 'spot-type' + : null; + if (!id) return; + window.setTimeout(() => { + document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + const focusable = document.querySelector(`#${id} input, #${id} textarea, #${id} button`); + focusable?.focus({ preventScroll: true }); + }, 0); + }; + const handleSubmit = async (e?: React.FormEvent) => { e?.preventDefault(); @@ -343,37 +390,25 @@ export default function CreateSpotPage() { 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); + scrollToFirstError(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'), - }); + window.setTimeout(() => { + document.getElementById('spot-nearby')?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 0); return; } } @@ -382,12 +417,16 @@ export default function CreateSpotPage() { // Generate geohash from coordinates (7 chars = ~153m precision) const geohash = ngeohash.encode(lat, lng, 7); - // Create unique identifier for addressable event - const identifier = title + // Create a unique identifier for the addressable event. Non-Latin titles + // still get a stable identifier instead of a bare timestamp. + const slug = title .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '') - .slice(0, 50) + '-' + Date.now().toString(36); + .slice(0, 50); + const identifier = `${slug || 'spot'}-${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 @@ -401,11 +440,7 @@ export default function CreateSpotPage() { 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(', '); - } + const locationStr = location.trim() || [geo.city, geo.state, geo.country].filter(Boolean).join(', '); // Build event tags const eventTags: string[][] = [ @@ -454,8 +489,9 @@ export default function CreateSpotPage() { // Add images for (const imageUrl of images) { - // Use imeta tag for NIP-92 compatibility - eventTags.push(['imeta', `url ${imageUrl}`, 'm image/jpeg']); + // Use imeta tag for NIP-92 compatibility. Preserve the real image type + // where the URL exposes it; do not label every upload as JPEG. + eventTags.push(['imeta', `url ${imageUrl}`, `m ${mimeForImageUrl(imageUrl)}`]); } // Add alt tag for NIP-31 (human-readable description) @@ -468,14 +504,25 @@ export default function CreateSpotPage() { }); draft.clear(); + queryClient.invalidateQueries({ queryKey: ['skate-spots'] }); + saveLastMapView(lat, lng, 15); toast({ title: t('spotUi.spotAdded'), description: t('spotUi.spotAddedDesc'), + action: ( + navigate(`/create-spot?lat=${lat.toFixed(6)}&lng=${lng.toFixed(6)}`)} + > + {t('home.addSpot')} + + ), }); - // Navigate to spot detail using pubkey and identifier - navigate(`/spot/${user?.pubkey}/${identifier}`); + // Stay in the map flow: the map opens on the new pin instead of sending + // the contributor to a detail page they did not ask for. + navigate('/map'); } catch (error) { console.error('Failed to create spot:', error); toast({ @@ -488,517 +535,454 @@ export default function CreateSpotPage() { if (!user) { return ( - <> -
- - - -

{t('errors.loginRequired')}

-

- {t('createSpot.loginToAdd')} -

- -
-
-
- +
+ + + +

{t('errors.loginRequired')}

+

{t('createSpot.loginToAdd')}

+ +
+
+
); } + const nearestDuplicate = dupWarning?.[0]; + const defaultCenter = coords + ? ([coords.lat, coords.lng] as [number, number]) + : (() => { + const saved = getLastMapView(); + if (saved) return [saved.lat, saved.lon] as [number, number]; + const cached = getLastLocation(); + return cached ? ([cached.lat, cached.lng] as [number, number]) : undefined; + })(); + return ( - <> -
-
- - - {t('createSpot.backToMap')} - -

{t('createSpot.title')}

-

- {t('createSpot.subtitle')} -

-
+
+
+ + + {t('createSpot.backToMap')} + +

+ {t('createSpot.title')} +

+

{t('createSpot.subtitle')}

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

{t('createSpot.draftTitle')}

-

{t('createSpot.draftBody')}

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

{t('createSpot.draftTitle')}

+

{t('createSpot.draftBody')}

- )} + + +
+ )} -
- - - {t('spots.spotDetails')} - - {t('createSpot.requiredFields')} - - - - {/* ── Location (map picker) ─────────────────────────────── */} -
- - -
- - {t('createSpot.tapToPlace')} -
- {errors.location && ( -

{errors.location}

- )} - - - + + + + {t('spots.spotDetails')} + {t('createSpot.requiredFields')} + + + {/* ── Photos first: most people already took the picture ─────── */} +
+
+ + {t('createSpot.photoHint')} +
+
+ {images.map((url) => ( +
+ {t('spotUi.spotPhotoAlt')} - - -
- { setLatitude(e.target.value); clearError('location'); }} - inputMode="decimal" - /> - { setLongitude(e.target.value); clearError('location'); }} - inputMode="decimal" - /> -
-
- -
- - {/* Address (required) — the human-readable place name. */} -
- - { setLocation(e.target.value); clearError('address'); }} - className={cn(errors.address && 'border-red-500/60')} - /> - {errors.address &&

{errors.address}

} -
- - {/* Duplicate warning — appears as soon as the pin lands, right - below the map, instead of only on submit. */} - {dupWarning && ( -
-

- {t('spotUi.spotMayAlreadyExist')} -

-

- {dupWarning.length === 1 - ? t('spotUi.duplicateOne') - : t('spotUi.duplicateOther')} -

-
    - {dupWarning.map(({ spot, km }) => ( -
  • - - {spot.title || t('spotUi.untitledSpot')} · {t('spotUi.metresAway', { distance: Math.round(km * 1000) })} - -
  • - ))} -
-
- -
-
- )} - - {/* ── Title ─────────────────────────────────────────────── */} -
- - { setTitle(e.target.value); clearError('title'); }} - className={cn(errors.title && 'border-red-500/60')} + ))} + - {errors.title &&

{errors.title}

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