feat: streamline photo-first spot creation and location privacy
This commit is contained in:
parent
3f4fa29d55
commit
f1aa6400d5
39 changed files with 870 additions and 667 deletions
31
AGENTS.md
31
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
|
"optional". Access keeps its **Public default** (so it is always satisfied; the
|
||||||
`*` marks membership in the required set), and Address has real inline
|
`*` marks membership in the required set), and Address has real inline
|
||||||
validation (`createSpot.addressRequired`).
|
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
|
- **Map pin rendering (perf, 2026-09-29):** the spot/tour/event/crew marker
|
||||||
effect in `SpotMap.tsx` used to `clearLayers()` + rebuild **every** marker
|
effect in `SpotMap.tsx` used to `clearLayers()` + rebuild **every** marker
|
||||||
whenever the parent handed it fresh arrays, and added each marker with a
|
whenever the parent handed it fresh arrays, and added each marker with a
|
||||||
|
|
|
||||||
|
|
@ -261,7 +261,7 @@ export function CameraCapture({
|
||||||
<DialogHeader className="p-4 border-b border-gray-800">
|
<DialogHeader className="p-4 border-b border-gray-800">
|
||||||
<DialogTitle className="flex items-center gap-2 text-white">
|
<DialogTitle className="flex items-center gap-2 text-white">
|
||||||
<Camera className="h-5 w-5 text-[#ff0040]" />
|
<Camera className="h-5 w-5 text-[#ff0040]" />
|
||||||
Take a Photo
|
{t('spotUi.takePhoto')}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
@ -304,7 +304,6 @@ export function CameraCapture({
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
capture="environment"
|
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { useSasquatchGuide } from '@/hooks/useSasquatchGuide';
|
||||||
import { LanguageToggle } from '@/components/LanguageToggle';
|
import { LanguageToggle } from '@/components/LanguageToggle';
|
||||||
import { LANGUAGE_OPTIONS } from '@/lib/languageOptions';
|
import { LANGUAGE_OPTIONS } from '@/lib/languageOptions';
|
||||||
import { clearTranslationMemory } from '@/lib/translation';
|
import { clearTranslationMemory } from '@/lib/translation';
|
||||||
|
import { clearLocationHistory } from '@/lib/lastLocation';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts';
|
import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts';
|
||||||
|
|
@ -306,7 +307,10 @@ function MobileHeaderUserMenu({
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={account.id}
|
key={account.id}
|
||||||
onClick={() => setLogin(account.id)}
|
onClick={() => {
|
||||||
|
clearLocationHistory();
|
||||||
|
setLogin(account.id);
|
||||||
|
}}
|
||||||
className="flex items-center gap-2 cursor-pointer"
|
className="flex items-center gap-2 cursor-pointer"
|
||||||
>
|
>
|
||||||
<Avatar className="h-6 w-6">
|
<Avatar className="h-6 w-6">
|
||||||
|
|
@ -892,7 +896,7 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
||||||
{user && (
|
{user && (
|
||||||
<MobileHeaderUserMenu
|
<MobileHeaderUserMenu
|
||||||
pubkey={user.pubkey}
|
pubkey={user.pubkey}
|
||||||
onLogout={() => { clearTranslationMemory(); removeLogin(currentAccount?.id || ''); }}
|
onLogout={() => { clearTranslationMemory(); clearLocationHistory(); removeLogin(currentAccount?.id || ''); }}
|
||||||
onSetStatus={() => setShowStatusDialog(true)}
|
onSetStatus={() => setShowStatusDialog(true)}
|
||||||
onAddAccount={() => setShowAddAccount(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"
|
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={() => {
|
onClick={() => {
|
||||||
clearTranslationMemory();
|
clearTranslationMemory();
|
||||||
|
clearLocationHistory();
|
||||||
removeLogin(currentAccount?.id || '');
|
removeLogin(currentAccount?.id || '');
|
||||||
setMobileMenuOpen(false);
|
setMobileMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Loader2, Upload } from 'lucide-react';
|
import { Loader2, Upload } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
|
@ -43,9 +43,23 @@ export function PhotoDropzone({
|
||||||
}: PhotoDropzoneProps) {
|
}: PhotoDropzoneProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [dragging, setDragging] = useState(false);
|
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 busy = uploading || disabled;
|
||||||
const resolvedLabel = label ?? t('spotUi.addPhotos');
|
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) => {
|
const emit = (fileList: FileList | null) => {
|
||||||
if (!fileList) return;
|
if (!fileList) return;
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ interface SpotLocationPickerProps {
|
||||||
value: LatLng | null;
|
value: LatLng | null;
|
||||||
onChange: (coords: LatLng) => void;
|
onChange: (coords: LatLng) => void;
|
||||||
className?: string;
|
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). */
|
/** 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
|
* 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.
|
* 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 containerRef = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<L.Map | null>(null);
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
const markerRef = useRef<L.Marker | 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;
|
if (!el || mapRef.current) return;
|
||||||
|
|
||||||
const initial = initialValueRef.current;
|
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, {
|
const map = L.map(el, {
|
||||||
center,
|
center,
|
||||||
zoom: initial ? 15 : 12,
|
zoom: initial ? 15 : (defaultZoom ?? (defaultCenter ? 14 : 12)),
|
||||||
zoomControl: true,
|
zoomControl: true,
|
||||||
attributionControl: true,
|
attributionControl: true,
|
||||||
});
|
});
|
||||||
|
|
@ -86,6 +91,9 @@ export function SpotLocationPicker({ value, onChange, className }: SpotLocationP
|
||||||
mapRef.current = null;
|
mapRef.current = null;
|
||||||
markerRef.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`.
|
// Draw / move the draggable pin to match `value`.
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar.tsx'
|
||||||
import { WalletModal } from '@/components/WalletModal';
|
import { WalletModal } from '@/components/WalletModal';
|
||||||
import { clearDmDecryptCache } from '@/lib/dmDecryptCache';
|
import { clearDmDecryptCache } from '@/lib/dmDecryptCache';
|
||||||
import { clearTranslationMemory } from '@/lib/translation';
|
import { clearTranslationMemory } from '@/lib/translation';
|
||||||
|
import { clearLocationHistory } from '@/lib/lastLocation';
|
||||||
import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
|
import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
|
@ -63,7 +64,10 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) {
|
||||||
{otherUsers.map((user) => (
|
{otherUsers.map((user) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={user.id}
|
key={user.id}
|
||||||
onClick={() => setLogin(user.id)}
|
onClick={() => {
|
||||||
|
clearLocationHistory();
|
||||||
|
setLogin(user.id);
|
||||||
|
}}
|
||||||
className='flex items-center gap-2 cursor-pointer p-2 rounded-md'
|
className='flex items-center gap-2 cursor-pointer p-2 rounded-md'
|
||||||
>
|
>
|
||||||
<Avatar className='w-8 h-8'>
|
<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.
|
// another account on the same device can't read it from IndexedDB.
|
||||||
void clearDmDecryptCache(currentUser.id).catch(() => {});
|
void clearDmDecryptCache(currentUser.id).catch(() => {});
|
||||||
clearTranslationMemory();
|
clearTranslationMemory();
|
||||||
|
clearLocationHistory();
|
||||||
removeLogin(currentUser.id);
|
removeLogin(currentUser.id);
|
||||||
}}
|
}}
|
||||||
className='flex items-center gap-2 cursor-pointer p-2 rounded-md text-red-500'
|
className='flex items-center gap-2 cursor-pointer p-2 rounded-md text-red-500'
|
||||||
|
|
|
||||||
|
|
@ -34,17 +34,21 @@ export function useFormDraft<T>(
|
||||||
}
|
}
|
||||||
}, [storageKey]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (firstRun.current) {
|
if (firstRun.current) {
|
||||||
firstRun.current = false;
|
firstRun.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
const timer = window.setTimeout(() => {
|
||||||
localStorage.setItem(storageKey, serialized);
|
try {
|
||||||
} catch {
|
localStorage.setItem(storageKey, serialized);
|
||||||
// storage may be full/blocked — a lost draft is not fatal
|
} catch {
|
||||||
}
|
// storage may be full/blocked — a lost draft is not fatal
|
||||||
|
}
|
||||||
|
}, 400);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
}, [serialized, storageKey]);
|
}, [serialized, storageKey]);
|
||||||
|
|
||||||
const resume = () => {
|
const resume = () => {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
type NLoginType,
|
type NLoginType,
|
||||||
useNostrLogin,
|
useNostrLogin,
|
||||||
} from '@/lib/nostrLogin';
|
} from '@/lib/nostrLogin';
|
||||||
|
import { clearLocationHistory } from '@/lib/lastLocation';
|
||||||
|
|
||||||
// NOTE: This file should not be edited except for adding new login methods.
|
// 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 { logins, addLogin, setLogin, removeLogin } = useNostrLogin();
|
||||||
|
|
||||||
const addAndActivate = (login: NLoginType) => {
|
const addAndActivate = (login: NLoginType) => {
|
||||||
|
// A different account on the same device must not inherit the previous
|
||||||
|
// account's last map area.
|
||||||
|
clearLocationHistory();
|
||||||
addLogin(login);
|
addLogin(login);
|
||||||
setLogin(login.id);
|
setLogin(login.id);
|
||||||
};
|
};
|
||||||
|
|
@ -41,6 +45,7 @@ export function useLoginActions() {
|
||||||
// and pubkey is correctly stored and rehydrated from localStorage.
|
// and pubkey is correctly stored and rehydrated from localStorage.
|
||||||
amber(pubkey: string): void {
|
amber(pubkey: string): void {
|
||||||
const login = new NLogin('x-amber', pubkey, { method: 'nip55' }) as unknown as NLoginType;
|
const login = new NLogin('x-amber', pubkey, { method: 'nip55' }) as unknown as NLoginType;
|
||||||
|
clearLocationHistory();
|
||||||
addLogin(login);
|
addLogin(login);
|
||||||
setLogin(login.id);
|
setLogin(login.id);
|
||||||
},
|
},
|
||||||
|
|
@ -48,6 +53,7 @@ export function useLoginActions() {
|
||||||
async logout(): Promise<void> {
|
async logout(): Promise<void> {
|
||||||
const login = logins[0];
|
const login = logins[0];
|
||||||
if (login) {
|
if (login) {
|
||||||
|
clearLocationHistory();
|
||||||
removeLogin(login.id);
|
removeLogin(login.id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -472,6 +472,8 @@ const batch1 = {
|
||||||
"somethingWentWrong": "Hi ha hagut un error"
|
"somethingWentWrong": "Hi ha hagut un error"
|
||||||
},
|
},
|
||||||
"spotUi": {
|
"spotUi": {
|
||||||
|
exactPinPublic: 'La ubicació exacta és pública: no marquis casa teva.',
|
||||||
|
|
||||||
"claim": {
|
"claim": {
|
||||||
"areYouOwner": "És el teu spot?",
|
"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.",
|
"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.",
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,8 @@ const csBatch2 = {
|
||||||
addFirstSpot: "Přidat první spot",
|
addFirstSpot: "Přidat první spot",
|
||||||
},
|
},
|
||||||
spotUi: {
|
spotUi: {
|
||||||
|
exactPinPublic: 'Přesná poloha je veřejná – neoznačuj svůj domov.',
|
||||||
|
|
||||||
claim: {
|
claim: {
|
||||||
areYouOwner: "Je to tvůj spot?",
|
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.",
|
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.",
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,8 @@ const deBatch2 = {
|
||||||
addFirstSpot: "Ersten Spot hinzufügen",
|
addFirstSpot: "Ersten Spot hinzufügen",
|
||||||
},
|
},
|
||||||
spotUi: {
|
spotUi: {
|
||||||
|
exactPinPublic: 'Der genaue Pin ist öffentlich – markiere nicht dein Zuhause.',
|
||||||
|
|
||||||
claim: {
|
claim: {
|
||||||
areYouOwner: "Ist das dein Spot?",
|
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.",
|
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.",
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,8 @@ const elBatch2 = {
|
||||||
addFirstSpot: "Πρόσθεσε το πρώτο spot",
|
addFirstSpot: "Πρόσθεσε το πρώτο spot",
|
||||||
},
|
},
|
||||||
spotUi: {
|
spotUi: {
|
||||||
|
exactPinPublic: 'Η ακριβής τοποθεσία είναι δημόσια — μην καρφιτσώνεις το σπίτι σου.',
|
||||||
|
|
||||||
claim: {
|
claim: {
|
||||||
areYouOwner: "Είναι αυτό το spot σου;",
|
areYouOwner: "Είναι αυτό το spot σου;",
|
||||||
privateDesc: "Αυτό το spot έχει επισημανθεί ως ιδιωτικό. Αν σου ανήκει ή το διαχειρίζεσαι, διεκδίκησέ το — ένας moderator του SkateSpots επιβεβαιώνει την ιδιοκτησία και μετά αναλαμβάνεις την καταχώριση.",
|
privateDesc: "Αυτό το spot έχει επισημανθεί ως ιδιωτικό. Αν σου ανήκει ή το διαχειρίζεσαι, διεκδίκησέ το — ένας moderator του SkateSpots επιβεβαιώνει την ιδιοκτησία και μετά αναλαμβάνεις την καταχώριση.",
|
||||||
|
|
|
||||||
|
|
@ -1081,6 +1081,7 @@ mapUi: {
|
||||||
},
|
},
|
||||||
|
|
||||||
spotUi: {
|
spotUi: {
|
||||||
|
exactPinPublic: 'Exact pin is public — don\'t pin your home.',
|
||||||
claim: {
|
claim: {
|
||||||
areYouOwner: 'Is this your spot?',
|
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.',
|
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.',
|
||||||
|
|
|
||||||
|
|
@ -1081,6 +1081,7 @@ mapUi: {
|
||||||
},
|
},
|
||||||
|
|
||||||
spotUi: {
|
spotUi: {
|
||||||
|
exactPinPublic: 'La ubicación exacta es pública: no marques tu casa.',
|
||||||
claim: {
|
claim: {
|
||||||
areYouOwner: '¿Es este tu spot?',
|
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.',
|
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.',
|
||||||
|
|
|
||||||
|
|
@ -472,6 +472,8 @@ const batch1 = {
|
||||||
somethingWentWrong: "Zerbait gaizki joan da"
|
somethingWentWrong: "Zerbait gaizki joan da"
|
||||||
},
|
},
|
||||||
spotUi: {
|
spotUi: {
|
||||||
|
exactPinPublic: 'Kokapen zehatza publikoa da — ez markatu zure etxea.',
|
||||||
|
|
||||||
claim: {
|
claim: {
|
||||||
areYouOwner: "Hau zure tokia da?",
|
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.",
|
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.",
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,8 @@ const frBatch2 = {
|
||||||
addFirstSpot: "Ajouter le premier spot",
|
addFirstSpot: "Ajouter le premier spot",
|
||||||
},
|
},
|
||||||
spotUi: {
|
spotUi: {
|
||||||
|
exactPinPublic: 'L’emplacement exact est public — ne placez pas votre domicile.',
|
||||||
|
|
||||||
claim: {
|
claim: {
|
||||||
areYouOwner: "C'est votre spot ?",
|
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.",
|
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.",
|
||||||
|
|
|
||||||
|
|
@ -472,6 +472,8 @@ const batch1 = {
|
||||||
somethingWentWrong: "Algo saíu mal"
|
somethingWentWrong: "Algo saíu mal"
|
||||||
},
|
},
|
||||||
spotUi: {
|
spotUi: {
|
||||||
|
exactPinPublic: 'A localización exacta é pública: non marques a túa casa.',
|
||||||
|
|
||||||
claim: {
|
claim: {
|
||||||
areYouOwner: "É este o teu spot?",
|
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.",
|
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.",
|
||||||
|
|
|
||||||
|
|
@ -472,6 +472,8 @@ const batch1 = {
|
||||||
"somethingWentWrong": "कुछ गलत हो गया"
|
"somethingWentWrong": "कुछ गलत हो गया"
|
||||||
},
|
},
|
||||||
"spotUi": {
|
"spotUi": {
|
||||||
|
exactPinPublic: 'सटीक पिन सार्वजनिक है — अपना घर पिन न करें।',
|
||||||
|
|
||||||
"claim": {
|
"claim": {
|
||||||
"areYouOwner": "क्या यह आपका स्पॉट है?",
|
"areYouOwner": "क्या यह आपका स्पॉट है?",
|
||||||
"privateDesc": "यह स्पॉट निजी के रूप में चिह्नित है। अगर यह आपका है या आप इसे प्रबंधित करते हैं, तो इसे क्लेम करें — SkateSpots मॉडरेटर स्वामित्व की पुष्टि करता है, फिर आप लिस्टिंग संभालते हैं।",
|
"privateDesc": "यह स्पॉट निजी के रूप में चिह्नित है। अगर यह आपका है या आप इसे प्रबंधित करते हैं, तो इसे क्लेम करें — SkateSpots मॉडरेटर स्वामित्व की पुष्टि करता है, फिर आप लिस्टिंग संभालते हैं।",
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,8 @@ const idBatch2 = {
|
||||||
addFirstSpot: "Tambah Spot Pertama",
|
addFirstSpot: "Tambah Spot Pertama",
|
||||||
},
|
},
|
||||||
spotUi: {
|
spotUi: {
|
||||||
|
exactPinPublic: 'Pin akurat ini publik — jangan tandai rumahmu.',
|
||||||
|
|
||||||
claim: {
|
claim: {
|
||||||
areYouOwner: "Apakah ini spot Anda?",
|
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.",
|
privateDesc: "Spot ini ditandai privat. Jika Anda memiliki atau mengelolanya, klaim — moderator SkateSpots mengonfirmasi kepemilikan, lalu Anda mengambil alih listing.",
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,8 @@ const itBatch2 = {
|
||||||
addFirstSpot: "Aggiungi il primo spot",
|
addFirstSpot: "Aggiungi il primo spot",
|
||||||
},
|
},
|
||||||
spotUi: {
|
spotUi: {
|
||||||
|
exactPinPublic: 'La posizione esatta è pubblica: non segnare casa tua.',
|
||||||
|
|
||||||
claim: {
|
claim: {
|
||||||
areYouOwner: "È il tuo spot?",
|
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.",
|
privateDesc: "Questo spot è contrassegnato come privato. Se lo possiedi o lo gestisci, rivendicalo — un moderatore di SkateSpots conferma la proprietà, poi subentri nell'inserzione.",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
*/
|
*/
|
||||||
const jaBatch3 = {
|
const jaBatch3 = {
|
||||||
spotUi: {
|
spotUi: {
|
||||||
|
exactPinPublic: '正確なピンは公開されます。自宅はピン留めしないでください。',
|
||||||
|
|
||||||
claim: {
|
claim: {
|
||||||
areYouOwner: 'ここはあなたのスポットですか?',
|
areYouOwner: 'ここはあなたのスポットですか?',
|
||||||
privateDesc: 'このスポットは非公開に設定されています。所有者または管理者なら申請してください — SkateSpotsのモデレーターが所有権を確認したうえで、あなたが管理を引き継ぎます。',
|
privateDesc: 'このスポットは非公開に設定されています。所有者または管理者なら申請してください — SkateSpotsのモデレーターが所有権を確認したうえで、あなたが管理を引き継ぎます。',
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,8 @@ const koBatch2 = {
|
||||||
addFirstSpot: '첫 스팟 추가',
|
addFirstSpot: '첫 스팟 추가',
|
||||||
},
|
},
|
||||||
spotUi: {
|
spotUi: {
|
||||||
|
exactPinPublic: '정확한 핀은 공개됩니다. 집 위치는 찍지 마세요.',
|
||||||
|
|
||||||
claim: {
|
claim: {
|
||||||
areYouOwner: '이 스팟이 당신 것인가요?',
|
areYouOwner: '이 스팟이 당신 것인가요?',
|
||||||
privateDesc: '이 스팟은 비공개로 표시되어 있습니다. 소유자나 관리자라면 소유권을 주장하세요 — SkateSpots 중재자가 소유권을 확인한 뒤 목록을 넘겨받게 됩니다.',
|
privateDesc: '이 스팟은 비공개로 표시되어 있습니다. 소유자나 관리자라면 소유권을 주장하세요 — SkateSpots 중재자가 소유권을 확인한 뒤 목록을 넘겨받게 됩니다.',
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,8 @@ const nlBatch2 = {
|
||||||
addFirstSpot: "Eerste spot toevoegen",
|
addFirstSpot: "Eerste spot toevoegen",
|
||||||
},
|
},
|
||||||
spotUi: {
|
spotUi: {
|
||||||
|
exactPinPublic: 'De exacte pin is openbaar — pin niet je huis.',
|
||||||
|
|
||||||
claim: {
|
claim: {
|
||||||
areYouOwner: "Is dit jouw spot?",
|
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.",
|
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.",
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,8 @@ const plBatch2 = {
|
||||||
addFirstSpot: "Dodaj pierwszy Spot",
|
addFirstSpot: "Dodaj pierwszy Spot",
|
||||||
},
|
},
|
||||||
spotUi: {
|
spotUi: {
|
||||||
|
exactPinPublic: 'Dokładna lokalizacja jest publiczna — nie oznaczaj swojego domu.',
|
||||||
|
|
||||||
claim: {
|
claim: {
|
||||||
areYouOwner: "Czy to twój Spot?",
|
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.",
|
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.",
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ const drift = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
spotUi: {
|
spotUi: {
|
||||||
|
exactPinPublic: 'A localização exata é pública — não marque a sua casa.',
|
||||||
|
|
||||||
saveChanges: "Guardar alterações",
|
saveChanges: "Guardar alterações",
|
||||||
targetType: {
|
targetType: {
|
||||||
user: "Utilizador",
|
user: "Utilizador",
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,8 @@ const ptBatch2 = {
|
||||||
addFirstSpot: "Adicionar primeiro spot",
|
addFirstSpot: "Adicionar primeiro spot",
|
||||||
},
|
},
|
||||||
spotUi: {
|
spotUi: {
|
||||||
|
exactPinPublic: 'A localização exata é pública — não marque a sua casa.',
|
||||||
|
|
||||||
claim: {
|
claim: {
|
||||||
areYouOwner: "Este spot é seu?",
|
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.",
|
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.",
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,8 @@ const roBatch2 = {
|
||||||
addFirstSpot: "Adaugă primul spot",
|
addFirstSpot: "Adaugă primul spot",
|
||||||
},
|
},
|
||||||
spotUi: {
|
spotUi: {
|
||||||
|
exactPinPublic: 'Locația exactă este publică — nu marca locuința ta.',
|
||||||
|
|
||||||
claim: {
|
claim: {
|
||||||
areYouOwner: "Acesta este spotul tău?",
|
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.",
|
privateDesc: "Acest spot este marcat privat. Dacă îl deții sau îl administrezi, revendică-l — un moderator SkateSpots confirmă proprietatea, apoi preiei anunțul.",
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,8 @@ const ruBatch2 = {
|
||||||
"addFirstSpot": "Добавить первый спот"
|
"addFirstSpot": "Добавить первый спот"
|
||||||
},
|
},
|
||||||
"spotUi": {
|
"spotUi": {
|
||||||
|
exactPinPublic: 'Точная метка видна всем — не отмечайте свой дом.',
|
||||||
|
|
||||||
"claim": {
|
"claim": {
|
||||||
"areYouOwner": "Это ваш спот?",
|
"areYouOwner": "Это ваш спот?",
|
||||||
"privateDesc": "Этот спот отмечен как приватный. Если он принадлежит вам или вы им управляете, заявите права — модератор SkateSpots подтвердит владение, и вы возьмёте объявление под контроль.",
|
"privateDesc": "Этот спот отмечен как приватный. Если он принадлежит вам или вы им управляете, заявите права — модератор SkateSpots подтвердит владение, и вы возьмёте объявление под контроль.",
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,8 @@ const svBatch2 = {
|
||||||
addFirstSpot: 'Lägg till första spotten',
|
addFirstSpot: 'Lägg till första spotten',
|
||||||
},
|
},
|
||||||
spotUi: {
|
spotUi: {
|
||||||
|
exactPinPublic: 'Den exakta kartnålen är offentlig – markera inte ditt hem.',
|
||||||
|
|
||||||
claim: {
|
claim: {
|
||||||
areYouOwner: 'Är det här din spot?',
|
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.',
|
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.',
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
*/
|
*/
|
||||||
const thBatch3 = {
|
const thBatch3 = {
|
||||||
spotUi: {
|
spotUi: {
|
||||||
|
exactPinPublic: 'ตำแหน่งหมุดที่แม่นยำเป็นข้อมูลสาธารณะ — อย่าปักหมุดบ้านของคุณ',
|
||||||
|
|
||||||
claim: {
|
claim: {
|
||||||
areYouOwner: 'นี่คือสปอตของคุณหรือไม่?',
|
areYouOwner: 'นี่คือสปอตของคุณหรือไม่?',
|
||||||
privateDesc: 'สปอตนี้ถูกทำเครื่องหมายเป็นส่วนตัว ถ้าคุณเป็นเจ้าของหรือดูแลอยู่ ให้อ้างสิทธิ์ — ผู้ดูแล SkateSpots จะยืนยันความเป็นเจ้าของ แล้วคุณจึงเข้าควบคุมรายการได้',
|
privateDesc: 'สปอตนี้ถูกทำเครื่องหมายเป็นส่วนตัว ถ้าคุณเป็นเจ้าของหรือดูแลอยู่ ให้อ้างสิทธิ์ — ผู้ดูแล SkateSpots จะยืนยันความเป็นเจ้าของ แล้วคุณจึงเข้าควบคุมรายการได้',
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,8 @@ const trBatch2: Record<string, unknown> = {
|
||||||
addFirstSpot: "İlk Spotu Ekle",
|
addFirstSpot: "İlk Spotu Ekle",
|
||||||
},
|
},
|
||||||
spotUi: {
|
spotUi: {
|
||||||
|
exactPinPublic: 'Tam konum herkese açıktır — evinizi işaretlemeyin.',
|
||||||
|
|
||||||
claim: {
|
claim: {
|
||||||
areYouOwner: "Burası senin spotun mu?",
|
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.",
|
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.",
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,8 @@ const ukBatch2 = {
|
||||||
addFirstSpot: "Додати перший Spot"
|
addFirstSpot: "Додати перший Spot"
|
||||||
},
|
},
|
||||||
spotUi: {
|
spotUi: {
|
||||||
|
exactPinPublic: 'Точна позначка є публічною — не позначайте свій дім.',
|
||||||
|
|
||||||
claim: {
|
claim: {
|
||||||
areYouOwner: "Це ваш Spot?",
|
areYouOwner: "Це ваш Spot?",
|
||||||
privateDesc: "Цей Spot позначено як приватний. Якщо він належить вам або ви ним керуєте, заявіть на нього — модератор SkateSpots підтвердить право власності, і ви візьмете оголошення під контроль.",
|
privateDesc: "Цей Spot позначено як приватний. Якщо він належить вам або ви ним керуєте, заявіть на нього — модератор SkateSpots підтвердить право власності, і ви візьмете оголошення під контроль.",
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,8 @@ const viBatch2 = {
|
||||||
addFirstSpot: 'Thêm spot đầu tiên',
|
addFirstSpot: 'Thêm spot đầu tiên',
|
||||||
},
|
},
|
||||||
spotUi: {
|
spotUi: {
|
||||||
|
exactPinPublic: 'Vị trí chính xác là công khai — đừng ghim nhà của bạn.',
|
||||||
|
|
||||||
claim: {
|
claim: {
|
||||||
areYouOwner: 'Đây có phải spot của bạn?',
|
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.',
|
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.',
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,8 @@ const zhCNBatch2 = {
|
||||||
addFirstSpot: "添加第一个 Spot",
|
addFirstSpot: "添加第一个 Spot",
|
||||||
},
|
},
|
||||||
spotUi: {
|
spotUi: {
|
||||||
|
exactPinPublic: '精确位置会公开 — 请不要标记自己的家。',
|
||||||
|
|
||||||
claim: {
|
claim: {
|
||||||
areYouOwner: "这是你的 Spot 吗?",
|
areYouOwner: "这是你的 Spot 吗?",
|
||||||
privateDesc: "此 Spot 被标记为私人。如果你拥有或管理它,请认领——SkateSpots 版主确认所有权后,你即可接管该条目。",
|
privateDesc: "此 Spot 被标记为私人。如果你拥有或管理它,请认领——SkateSpots 版主确认所有权后,你即可接管该条目。",
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,8 @@ const zhTWBatch2 = {
|
||||||
addFirstSpot: "新增第一個 Spot",
|
addFirstSpot: "新增第一個 Spot",
|
||||||
},
|
},
|
||||||
spotUi: {
|
spotUi: {
|
||||||
|
exactPinPublic: '精確位置會公開 — 請不要標記自己的家。',
|
||||||
|
|
||||||
claim: {
|
claim: {
|
||||||
areYouOwner: "這是你的 Spot 嗎?",
|
areYouOwner: "這是你的 Spot 嗎?",
|
||||||
privateDesc: "此 Spot 被標記為私人。如果你擁有或管理它,請認領——SkateSpots 版主確認所有權後,你即可接管該條目。",
|
privateDesc: "此 Spot 被標記為私人。如果你擁有或管理它,請認領——SkateSpots 版主確認所有權後,你即可接管該條目。",
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,9 @@
|
||||||
* resolves in the background.
|
* resolves in the background.
|
||||||
*
|
*
|
||||||
* This is a coarse convenience cache, not a source of truth — a fresh fix always
|
* 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
|
* supersedes it. It also stores the last map view so the Add Spot form can start
|
||||||
* it lives only in this browser/app.
|
* near the place the user was already looking at. Both are local-only and are
|
||||||
|
* cleared on account switch/logout.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface LastLocation {
|
export interface LastLocation {
|
||||||
|
|
@ -20,10 +21,18 @@ export interface LastLocation {
|
||||||
}
|
}
|
||||||
|
|
||||||
const KEY = 'skatespots:last-location';
|
const KEY = 'skatespots:last-location';
|
||||||
|
const MAP_VIEW_KEY = 'skatespots:map-view';
|
||||||
|
|
||||||
/** Consider a cached location "usable for centering" for 30 days. */
|
/** Consider a cached location "usable for centering" for 30 days. */
|
||||||
const MAX_AGE = 30 * 24 * 60 * 60 * 1000;
|
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. */
|
/** Persist a successful location fix. Never throws. */
|
||||||
export function saveLastLocation(lat: number, lng: number): void {
|
export function saveLastLocation(lat: number, lng: number): void {
|
||||||
try {
|
try {
|
||||||
|
|
@ -65,3 +74,61 @@ export function getLastLocation(): LastLocation | null {
|
||||||
return 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
|
|
@ -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 { useSeoMeta } from '@unhead/react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
|
@ -94,7 +94,7 @@ import { SpotChat } from '@/components/SpotChat';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getCurrentPosition, GeolocationError, isNativeGeolocation } from '@/lib/geolocation';
|
import { getCurrentPosition, GeolocationError, isNativeGeolocation } from '@/lib/geolocation';
|
||||||
import { getLastLocation } from '@/lib/lastLocation';
|
import { getLastLocation, getLastMapView, saveLastMapView } from '@/lib/lastLocation';
|
||||||
|
|
||||||
|
|
||||||
type ViewMode = 'map' | 'list';
|
type ViewMode = 'map' | 'list';
|
||||||
|
|
@ -644,7 +644,7 @@ export default function MapPage() {
|
||||||
const cached = getLastLocation();
|
const cached = getLastLocation();
|
||||||
return cached ? { lat: cached.lat, lon: cached.lng } : null;
|
return cached ? { lat: cached.lat, lon: cached.lng } : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Map entity type filters (state lives above the queries so hidden layers
|
// Map entity type filters (state lives above the queries so hidden layers
|
||||||
// don't open relay requests).
|
// don't open relay requests).
|
||||||
const [showBtcMap, setShowBtcMap] = useState(false);
|
const [showBtcMap, setShowBtcMap] = useState(false);
|
||||||
|
|
@ -680,24 +680,14 @@ export default function MapPage() {
|
||||||
const [initialView] = useState(() => {
|
const [initialView] = useState(() => {
|
||||||
// Restore the last map view first; a brand-new device falls back to the
|
// Restore the last map view first; a brand-new device falls back to the
|
||||||
// cached location, then to the world view.
|
// cached location, then to the world view.
|
||||||
try {
|
const saved = getLastMapView();
|
||||||
const raw = localStorage.getItem('skatespots:map-view');
|
if (saved) {
|
||||||
const saved = raw ? JSON.parse(raw) as { lat?: unknown; lon?: unknown; zoom?: unknown } : null;
|
return {
|
||||||
if (
|
center: [saved.lat, saved.lon] as [number, number],
|
||||||
saved &&
|
zoom: Math.min(18, Math.max(1, saved.zoom)),
|
||||||
typeof saved.lat === 'number' && Number.isFinite(saved.lat) &&
|
lat: saved.lat,
|
||||||
typeof saved.lon === 'number' && Number.isFinite(saved.lon) &&
|
lon: 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 cached = getLastLocation();
|
const cached = getLastLocation();
|
||||||
return cached
|
return cached
|
||||||
|
|
@ -715,19 +705,32 @@ export default function MapPage() {
|
||||||
});
|
});
|
||||||
const handleMapMove = (center: { lat: number; lon: number; zoom?: number }) => {
|
const handleMapMove = (center: { lat: number; lon: number; zoom?: number }) => {
|
||||||
setMapCenter({ lat: center.lat, lon: center.lon });
|
setMapCenter({ lat: center.lat, lon: center.lon });
|
||||||
try {
|
saveLastMapView(center.lat, center.lon, center.zoom ?? 12);
|
||||||
localStorage.setItem('skatespots:map-view', JSON.stringify(center));
|
|
||||||
} catch {
|
|
||||||
// storage unavailable — map still works
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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
|
// Near Me fly-to state
|
||||||
const [flyTo, setFlyTo] = useState<{ lat: number; lng: number; zoom?: number } | null>(null);
|
const [flyTo, setFlyTo] = useState<{ lat: number; lng: number; zoom?: number } | null>(null);
|
||||||
const [nearMeLoading, setNearMeLoading] = useState(false);
|
const [nearMeLoading, setNearMeLoading] = useState(false);
|
||||||
const [nearMeError, setNearMeError] = useState<'denied' | 'unavailable' | 'timeout' | null>(null);
|
const [nearMeError, setNearMeError] = useState<'denied' | 'unavailable' | 'timeout' | null>(null);
|
||||||
const [showLocationHelp, setShowLocationHelp] = useState(false);
|
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 () => {
|
const handleNearMe = async () => {
|
||||||
|
|
@ -957,6 +960,7 @@ export default function MapPage() {
|
||||||
// Drop a pin and ask first — do NOT navigate immediately. The user confirms
|
// 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.
|
// with the card below, which is the only thing that opens the create form.
|
||||||
setSelectedSpot(null);
|
setSelectedSpot(null);
|
||||||
|
setAddDialOpen(false);
|
||||||
setPendingAddPin({ lat, lng });
|
setPendingAddPin({ lat, lng });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1695,7 +1699,7 @@ export default function MapPage() {
|
||||||
{user ? (
|
{user ? (
|
||||||
<>
|
<>
|
||||||
{/* Primary action: add a spot (this is a map, after all) */}
|
{/* 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">
|
<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" />
|
<Plus className="h-4 w-4" />
|
||||||
<span className="hidden md:inline">{t('home.addSpot')}</span>
|
<span className="hidden md:inline">{t('home.addSpot')}</span>
|
||||||
|
|
@ -1707,7 +1711,7 @@ export default function MapPage() {
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
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" />
|
<Plus className="h-4 w-4" />
|
||||||
<span className="hidden md:inline">{t('mapUi.more')}</span>
|
<span className="hidden md:inline">{t('mapUi.more')}</span>
|
||||||
|
|
@ -1966,7 +1970,7 @@ export default function MapPage() {
|
||||||
{t('mapUi.noSpotsMappedDesc')}
|
{t('mapUi.noSpotsMappedDesc')}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:justify-center">
|
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:justify-center">
|
||||||
<Link to="/create-spot">
|
<Link to={createSpotHref}>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full gap-2 bg-[#ccff00] font-black uppercase text-black hover:bg-[#ccff00]/90"
|
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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed right-4 z-[1001] md:hidden flex flex-col items-end gap-2.5 transition-all duration-200',
|
'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)' }}
|
style={{ bottom: 'calc(3.5rem + env(safe-area-inset-bottom, 0px) + 1rem)' }}
|
||||||
>
|
>
|
||||||
|
|
@ -2169,7 +2173,7 @@ export default function MapPage() {
|
||||||
{addDialOpen && (
|
{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.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.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" /> },
|
{ 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>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
user && (
|
user && (
|
||||||
<Link to="/create-spot">
|
<Link to={createSpotHref}>
|
||||||
<Button className="gap-2">
|
<Button className="gap-2">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
{t('mapPage.addFirstSpot')}
|
{t('mapPage.addFirstSpot')}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNostr } from '@nostrify/react';
|
import { useNostr } from '@nostrify/react';
|
||||||
import { NSchema as n, type NostrMetadata } from '@nostrify/nostrify';
|
import { NSchema as n, type NostrMetadata } from '@nostrify/nostrify';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { clearLocationHistory } from '@/lib/lastLocation';
|
||||||
import { LoginArea } from '@/components/auth/LoginArea';
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
|
@ -619,6 +620,7 @@ function RelaysScreen({ onBack }: { onBack: () => void }) {
|
||||||
function AppearanceScreen({ onBack }: { onBack: () => void }) {
|
function AppearanceScreen({ onBack }: { onBack: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [sasquatchHidden, setSasquatchHidden] = useSasquatchHidden();
|
const [sasquatchHidden, setSasquatchHidden] = useSasquatchHidden();
|
||||||
|
const [locationCleared, setLocationCleared] = useState(false);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<BackHeader title={t('settingsUi.settingsPage.appearance')} onBack={onBack} />
|
<BackHeader title={t('settingsUi.settingsPage.appearance')} onBack={onBack} />
|
||||||
|
|
@ -650,6 +652,28 @@ function AppearanceScreen({ onBack }: { onBack: () => void }) {
|
||||||
</div>
|
</div>
|
||||||
</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="rounded-2xl border border-white/8 p-5 bg-white/2 text-center">
|
||||||
<div className="text-3xl mb-3">🌙</div>
|
<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>
|
<p className="font-black text-white uppercase tracking-wide text-sm mb-2">{t('settingsUi.settingsPage.darkModeOnly')}</p>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue