feat: streamline photo-first spot creation and location privacy

This commit is contained in:
SkateSpots 2026-11-02 14:11:58 +07:00
parent 3f4fa29d55
commit f1aa6400d5
39 changed files with 870 additions and 667 deletions

View file

@ -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

View file

@ -261,7 +261,7 @@ export function CameraCapture({
<DialogHeader className="p-4 border-b border-gray-800">
<DialogTitle className="flex items-center gap-2 text-white">
<Camera className="h-5 w-5 text-[#ff0040]" />
Take a Photo
{t('spotUi.takePhoto')}
</DialogTitle>
</DialogHeader>
@ -304,7 +304,6 @@ export function CameraCapture({
<input
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];

View file

@ -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 (
<DropdownMenuItem
key={account.id}
onClick={() => setLogin(account.id)}
onClick={() => {
clearLocationHistory();
setLogin(account.id);
}}
className="flex items-center gap-2 cursor-pointer"
>
<Avatar className="h-6 w-6">
@ -892,7 +896,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
{user && (
<MobileHeaderUserMenu
pubkey={user.pubkey}
onLogout={() => { 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);
}}

View file

@ -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;

View file

@ -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<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
const markerRef = useRef<L.Marker | null>(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`.

View file

@ -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) => (
<DropdownMenuItem
key={user.id}
onClick={() => setLogin(user.id)}
onClick={() => {
clearLocationHistory();
setLogin(user.id);
}}
className='flex items-center gap-2 cursor-pointer p-2 rounded-md'
>
<Avatar className='w-8 h-8'>
@ -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'

View file

@ -34,17 +34,21 @@ export function useFormDraft<T>(
}
}, [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 = () => {

View file

@ -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<void> {
const login = logins[0];
if (login) {
clearLocationHistory();
removeLogin(login.id);
}
},

View file

@ -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.",

View file

@ -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.",

View file

@ -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.",

View file

@ -196,6 +196,8 @@ const elBatch2 = {
addFirstSpot: "Πρόσθεσε το πρώτο spot",
},
spotUi: {
exactPinPublic: 'Η ακριβής τοποθεσία είναι δημόσια — μην καρφιτσώνεις το σπίτι σου.',
claim: {
areYouOwner: "Είναι αυτό το spot σου;",
privateDesc: "Αυτό το spot έχει επισημανθεί ως ιδιωτικό. Αν σου ανήκει ή το διαχειρίζεσαι, διεκδίκησέ το — ένας moderator του SkateSpots επιβεβαιώνει την ιδιοκτησία και μετά αναλαμβάνεις την καταχώριση.",

View file

@ -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.',

View file

@ -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.',

View file

@ -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.",

View file

@ -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.",

View file

@ -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.",

View file

@ -472,6 +472,8 @@ const batch1 = {
"somethingWentWrong": "कुछ गलत हो गया"
},
"spotUi": {
exactPinPublic: 'सटीक पिन सार्वजनिक है — अपना घर पिन न करें।',
"claim": {
"areYouOwner": "क्या यह आपका स्पॉट है?",
"privateDesc": "यह स्पॉट निजी के रूप में चिह्नित है। अगर यह आपका है या आप इसे प्रबंधित करते हैं, तो इसे क्लेम करें — SkateSpots मॉडरेटर स्वामित्व की पुष्टि करता है, फिर आप लिस्टिंग संभालते हैं।",

View file

@ -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.",

View file

@ -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.",

View file

@ -5,6 +5,8 @@
*/
const jaBatch3 = {
spotUi: {
exactPinPublic: '正確なピンは公開されます。自宅はピン留めしないでください。',
claim: {
areYouOwner: 'ここはあなたのスポットですか?',
privateDesc: 'このスポットは非公開に設定されています。所有者または管理者なら申請してください — SkateSpotsのモデレーターが所有権を確認したうえで、あなたが管理を引き継ぎます。',

View file

@ -201,6 +201,8 @@ const koBatch2 = {
addFirstSpot: '첫 스팟 추가',
},
spotUi: {
exactPinPublic: '정확한 핀은 공개됩니다. 집 위치는 찍지 마세요.',
claim: {
areYouOwner: '이 스팟이 당신 것인가요?',
privateDesc: '이 스팟은 비공개로 표시되어 있습니다. 소유자나 관리자라면 소유권을 주장하세요 — SkateSpots 중재자가 소유권을 확인한 뒤 목록을 넘겨받게 됩니다.',

View file

@ -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.",

View file

@ -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.",

View file

@ -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",

View file

@ -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.",

View file

@ -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.",

View file

@ -196,6 +196,8 @@ const ruBatch2 = {
"addFirstSpot": "Добавить первый спот"
},
"spotUi": {
exactPinPublic: 'Точная метка видна всем — не отмечайте свой дом.',
"claim": {
"areYouOwner": "Это ваш спот?",
"privateDesc": "Этот спот отмечен как приватный. Если он принадлежит вам или вы им управляете, заявите права — модератор SkateSpots подтвердит владение, и вы возьмёте объявление под контроль.",

View file

@ -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.',

View file

@ -6,6 +6,8 @@
*/
const thBatch3 = {
spotUi: {
exactPinPublic: 'ตำแหน่งหมุดที่แม่นยำเป็นข้อมูลสาธารณะ — อย่าปักหมุดบ้านของคุณ',
claim: {
areYouOwner: 'นี่คือสปอตของคุณหรือไม่?',
privateDesc: 'สปอตนี้ถูกทำเครื่องหมายเป็นส่วนตัว ถ้าคุณเป็นเจ้าของหรือดูแลอยู่ ให้อ้างสิทธิ์ — ผู้ดูแล SkateSpots จะยืนยันความเป็นเจ้าของ แล้วคุณจึงเข้าควบคุมรายการได้',

View file

@ -196,6 +196,8 @@ const trBatch2: Record<string, unknown> = {
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.",

View file

@ -196,6 +196,8 @@ const ukBatch2 = {
addFirstSpot: "Додати перший Spot"
},
spotUi: {
exactPinPublic: 'Точна позначка є публічною — не позначайте свій дім.',
claim: {
areYouOwner: "Це ваш Spot?",
privateDesc: "Цей Spot позначено як приватний. Якщо він належить вам або ви ним керуєте, заявіть на нього — модератор SkateSpots підтвердить право власності, і ви візьмете оголошення під контроль.",

View file

@ -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.',

View file

@ -202,6 +202,8 @@ const zhCNBatch2 = {
addFirstSpot: "添加第一个 Spot",
},
spotUi: {
exactPinPublic: '精确位置会公开 — 请不要标记自己的家。',
claim: {
areYouOwner: "这是你的 Spot 吗?",
privateDesc: "此 Spot 被标记为私人。如果你拥有或管理它,请认领——SkateSpots 版主确认所有权后,你即可接管该条目。",

View file

@ -202,6 +202,8 @@ const zhTWBatch2 = {
addFirstSpot: "新增第一個 Spot",
},
spotUi: {
exactPinPublic: '精確位置會公開 — 請不要標記自己的家。',
claim: {
areYouOwner: "這是你的 Spot 嗎?",
privateDesc: "此 Spot 被標記為私人。如果你擁有或管理它,請認領——SkateSpots 版主確認所有權後,你即可接管該條目。",

View file

@ -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<LastMapView>;
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
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
import { useState, useMemo, useEffect, lazy, Suspense, useCallback } from 'react';
import { useState, useMemo, useEffect, useRef, lazy, Suspense, useCallback } from 'react';
import { useSeoMeta } from '@unhead/react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
@ -94,7 +94,7 @@ import { SpotChat } from '@/components/SpotChat';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
import { getCurrentPosition, GeolocationError, isNativeGeolocation } from '@/lib/geolocation';
import { getLastLocation } from '@/lib/lastLocation';
import { getLastLocation, getLastMapView, saveLastMapView } from '@/lib/lastLocation';
type ViewMode = 'map' | 'list';
@ -644,7 +644,7 @@ export default function MapPage() {
const cached = getLastLocation();
return cached ? { lat: cached.lat, lon: cached.lng } : null;
});
// Map entity type filters (state lives above the queries so hidden layers
// don't open relay requests).
const [showBtcMap, setShowBtcMap] = useState(false);
@ -680,24 +680,14 @@ export default function MapPage() {
const [initialView] = useState(() => {
// Restore the last map view first; a brand-new device falls back to the
// cached location, then to the world view.
try {
const raw = localStorage.getItem('skatespots:map-view');
const saved = raw ? JSON.parse(raw) as { lat?: unknown; lon?: unknown; zoom?: unknown } : null;
if (
saved &&
typeof saved.lat === 'number' && Number.isFinite(saved.lat) &&
typeof saved.lon === 'number' && Number.isFinite(saved.lon) &&
typeof saved.zoom === 'number' && Number.isFinite(saved.zoom)
) {
return {
center: [saved.lat, saved.lon] as [number, number],
zoom: Math.min(18, Math.max(1, saved.zoom)),
lat: saved.lat,
lon: saved.lon,
};
}
} catch {
// fall through to the location/world default
const saved = getLastMapView();
if (saved) {
return {
center: [saved.lat, saved.lon] as [number, number],
zoom: Math.min(18, Math.max(1, saved.zoom)),
lat: saved.lat,
lon: saved.lon,
};
}
const cached = getLastLocation();
return cached
@ -715,19 +705,32 @@ export default function MapPage() {
});
const handleMapMove = (center: { lat: number; lon: number; zoom?: number }) => {
setMapCenter({ lat: center.lat, lon: center.lon });
try {
localStorage.setItem('skatespots:map-view', JSON.stringify(center));
} catch {
// storage unavailable — map still works
}
saveLastMapView(center.lat, center.lon, center.zoom ?? 12);
};
// Every Add Spot entry point should carry the place the user is already
// looking at; otherwise the form has to guess with a stale/default location.
const createSpotHref = `/create-spot?lat=${mapCenter.lat.toFixed(6)}&lng=${mapCenter.lon.toFixed(6)}`;
// Near Me fly-to state
const [flyTo, setFlyTo] = useState<{ lat: number; lng: number; zoom?: number } | null>(null);
const [nearMeLoading, setNearMeLoading] = useState(false);
const [nearMeError, setNearMeError] = useState<'denied' | 'unavailable' | 'timeout' | null>(null);
const [showLocationHelp, setShowLocationHelp] = useState(false);
// Do not let a second account inherit the previous account's live-location
// dot/sort state when accounts are switched without a reload.
const lastUserPubkeyRef = useRef(user?.pubkey ?? null);
useEffect(() => {
const next = user?.pubkey ?? null;
if (lastUserPubkeyRef.current && lastUserPubkeyRef.current !== next) {
setUserLocation(null);
setNearMeError(null);
setSortBy('recent');
}
lastUserPubkeyRef.current = next;
}, [user?.pubkey]);
const handleNearMe = async () => {
@ -957,6 +960,7 @@ export default function MapPage() {
// Drop a pin and ask first — do NOT navigate immediately. The user confirms
// with the card below, which is the only thing that opens the create form.
setSelectedSpot(null);
setAddDialOpen(false);
setPendingAddPin({ lat, lng });
};
@ -1695,7 +1699,7 @@ export default function MapPage() {
{user ? (
<>
{/* Primary action: add a spot (this is a map, after all) */}
<Link to="/create-spot" className="flex-shrink-0">
<Link to={createSpotHref} className="hidden md:flex flex-shrink-0">
<Button size="sm" className="gap-1.5 bg-[#ff0040] hover:bg-[#ff0040]/90 border-2 border-[#ff0040] font-bold h-8 px-2 sm:px-3">
<Plus className="h-4 w-4" />
<span className="hidden md:inline">{t('home.addSpot')}</span>
@ -1707,7 +1711,7 @@ export default function MapPage() {
<Button
size="sm"
variant="outline"
className="flex-shrink-0 gap-1 border-2 border-gray-700 hover:border-[#ccff00]/50 font-bold h-8 px-2 sm:px-3"
className="hidden md:inline-flex flex-shrink-0 gap-1 border-2 border-gray-700 hover:border-[#ccff00]/50 font-bold h-8 px-2 sm:px-3"
>
<Plus className="h-4 w-4" />
<span className="hidden md:inline">{t('mapUi.more')}</span>
@ -1966,7 +1970,7 @@ export default function MapPage() {
{t('mapUi.noSpotsMappedDesc')}
</p>
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:justify-center">
<Link to="/create-spot">
<Link to={createSpotHref}>
<Button
size="sm"
className="w-full gap-2 bg-[#ccff00] font-black uppercase text-black hover:bg-[#ccff00]/90"
@ -2161,7 +2165,7 @@ export default function MapPage() {
<div
className={cn(
'fixed right-4 z-[1001] md:hidden flex flex-col items-end gap-2.5 transition-all duration-200',
mobileFiltersOpen || selectedSpot ? 'opacity-0 pointer-events-none' : 'opacity-100'
mobileFiltersOpen || selectedSpot || pendingAddPin ? 'opacity-0 pointer-events-none' : 'opacity-100'
)}
style={{ bottom: 'calc(3.5rem + env(safe-area-inset-bottom, 0px) + 1rem)' }}
>
@ -2169,7 +2173,7 @@ export default function MapPage() {
{addDialOpen && (
<>
{[
{ label: t('mapUi.addSpot'), to: '/create-spot', bg: 'bg-[#ff0040]', icon: <MapPin className="h-4 w-4 text-white" /> },
{ label: t('mapUi.addSpot'), to: createSpotHref, bg: 'bg-[#ff0040]', icon: <MapPin className="h-4 w-4 text-white" /> },
{ label: t('mapUi.addShop'), to: '/create-shop', bg: 'bg-[#9333ea]', icon: <Store className="h-4 w-4 text-white" /> },
{ label: t('mapUi.addEvent'), to: '/create-event', bg: 'bg-[#ccff00]', icon: <Calendar className="h-4 w-4 text-black" /> },
{ label: t('mapUi.sellGear'), to: '/create-listing', bg: 'bg-[#f97316]', icon: <ShoppingBag className="h-4 w-4 text-white" /> },
@ -2259,7 +2263,7 @@ export default function MapPage() {
</Button>
) : (
user && (
<Link to="/create-spot">
<Link to={createSpotHref}>
<Button className="gap-2">
<Plus className="h-4 w-4" />
{t('mapPage.addFirstSpot')}

View file

@ -28,6 +28,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { NSchema as n, type NostrMetadata } from '@nostrify/nostrify';
import { useState } from 'react';
import { clearLocationHistory } from '@/lib/lastLocation';
import { LoginArea } from '@/components/auth/LoginArea';
import { Button } from '@/components/ui/button';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@ -619,6 +620,7 @@ function RelaysScreen({ onBack }: { onBack: () => void }) {
function AppearanceScreen({ onBack }: { onBack: () => void }) {
const { t } = useTranslation();
const [sasquatchHidden, setSasquatchHidden] = useSasquatchHidden();
const [locationCleared, setLocationCleared] = useState(false);
return (
<div>
<BackHeader title={t('settingsUi.settingsPage.appearance')} onBack={onBack} />
@ -650,6 +652,28 @@ function AppearanceScreen({ onBack }: { onBack: () => void }) {
</div>
</div>
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 mb-2">{t('mapPage.nearMe')}</p>
<div className="rounded-2xl border border-white/8 p-4 bg-white/2 flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-bold text-white">{t('settingsUi.privacy.locationTitle')}</p>
<p className="text-xs text-muted-foreground">{t('settingsUi.privacy.locationGood1')}</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
clearLocationHistory();
setLocationCleared(true);
toast({ title: t('common.done'), description: t('settingsUi.privacy.locationGood1') });
}}
>
{locationCleared ? t('common.done') : t('common.clear')}
</Button>
</div>
</div>
<div className="rounded-2xl border border-white/8 p-5 bg-white/2 text-center">
<div className="text-3xl mb-3">🌙</div>
<p className="font-black text-white uppercase tracking-wide text-sm mb-2">{t('settingsUi.settingsPage.darkModeOnly')}</p>