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
|
||||
`*` 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
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
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 = () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -196,6 +196,8 @@ const elBatch2 = {
|
|||
addFirstSpot: "Πρόσθεσε το πρώτο spot",
|
||||
},
|
||||
spotUi: {
|
||||
exactPinPublic: 'Η ακριβής τοποθεσία είναι δημόσια — μην καρφιτσώνεις το σπίτι σου.',
|
||||
|
||||
claim: {
|
||||
areYouOwner: "Είναι αυτό το spot σου;",
|
||||
privateDesc: "Αυτό το spot έχει επισημανθεί ως ιδιωτικό. Αν σου ανήκει ή το διαχειρίζεσαι, διεκδίκησέ το — ένας moderator του SkateSpots επιβεβαιώνει την ιδιοκτησία και μετά αναλαμβάνεις την καταχώριση.",
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -472,6 +472,8 @@ const batch1 = {
|
|||
"somethingWentWrong": "कुछ गलत हो गया"
|
||||
},
|
||||
"spotUi": {
|
||||
exactPinPublic: 'सटीक पिन सार्वजनिक है — अपना घर पिन न करें।',
|
||||
|
||||
"claim": {
|
||||
"areYouOwner": "क्या यह आपका स्पॉट है?",
|
||||
"privateDesc": "यह स्पॉट निजी के रूप में चिह्नित है। अगर यह आपका है या आप इसे प्रबंधित करते हैं, तो इसे क्लेम करें — SkateSpots मॉडरेटर स्वामित्व की पुष्टि करता है, फिर आप लिस्टिंग संभालते हैं।",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
*/
|
||||
const jaBatch3 = {
|
||||
spotUi: {
|
||||
exactPinPublic: '正確なピンは公開されます。自宅はピン留めしないでください。',
|
||||
|
||||
claim: {
|
||||
areYouOwner: 'ここはあなたのスポットですか?',
|
||||
privateDesc: 'このスポットは非公開に設定されています。所有者または管理者なら申請してください — SkateSpotsのモデレーターが所有権を確認したうえで、あなたが管理を引き継ぎます。',
|
||||
|
|
|
|||
|
|
@ -201,6 +201,8 @@ const koBatch2 = {
|
|||
addFirstSpot: '첫 스팟 추가',
|
||||
},
|
||||
spotUi: {
|
||||
exactPinPublic: '정확한 핀은 공개됩니다. 집 위치는 찍지 마세요.',
|
||||
|
||||
claim: {
|
||||
areYouOwner: '이 스팟이 당신 것인가요?',
|
||||
privateDesc: '이 스팟은 비공개로 표시되어 있습니다. 소유자나 관리자라면 소유권을 주장하세요 — SkateSpots 중재자가 소유권을 확인한 뒤 목록을 넘겨받게 됩니다.',
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -196,6 +196,8 @@ const ruBatch2 = {
|
|||
"addFirstSpot": "Добавить первый спот"
|
||||
},
|
||||
"spotUi": {
|
||||
exactPinPublic: 'Точная метка видна всем — не отмечайте свой дом.',
|
||||
|
||||
"claim": {
|
||||
"areYouOwner": "Это ваш спот?",
|
||||
"privateDesc": "Этот спот отмечен как приватный. Если он принадлежит вам или вы им управляете, заявите права — модератор SkateSpots подтвердит владение, и вы возьмёте объявление под контроль.",
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
*/
|
||||
const thBatch3 = {
|
||||
spotUi: {
|
||||
exactPinPublic: 'ตำแหน่งหมุดที่แม่นยำเป็นข้อมูลสาธารณะ — อย่าปักหมุดบ้านของคุณ',
|
||||
|
||||
claim: {
|
||||
areYouOwner: 'นี่คือสปอตของคุณหรือไม่?',
|
||||
privateDesc: 'สปอตนี้ถูกทำเครื่องหมายเป็นส่วนตัว ถ้าคุณเป็นเจ้าของหรือดูแลอยู่ ให้อ้างสิทธิ์ — ผู้ดูแล SkateSpots จะยืนยันความเป็นเจ้าของ แล้วคุณจึงเข้าควบคุมรายการได้',
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -196,6 +196,8 @@ const ukBatch2 = {
|
|||
addFirstSpot: "Додати перший Spot"
|
||||
},
|
||||
spotUi: {
|
||||
exactPinPublic: 'Точна позначка є публічною — не позначайте свій дім.',
|
||||
|
||||
claim: {
|
||||
areYouOwner: "Це ваш Spot?",
|
||||
privateDesc: "Цей Spot позначено як приватний. Якщо він належить вам або ви ним керуєте, заявіть на нього — модератор SkateSpots підтвердить право власності, і ви візьмете оголошення під контроль.",
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -202,6 +202,8 @@ const zhCNBatch2 = {
|
|||
addFirstSpot: "添加第一个 Spot",
|
||||
},
|
||||
spotUi: {
|
||||
exactPinPublic: '精确位置会公开 — 请不要标记自己的家。',
|
||||
|
||||
claim: {
|
||||
areYouOwner: "这是你的 Spot 吗?",
|
||||
privateDesc: "此 Spot 被标记为私人。如果你拥有或管理它,请认领——SkateSpots 版主确认所有权后,你即可接管该条目。",
|
||||
|
|
|
|||
|
|
@ -202,6 +202,8 @@ const zhTWBatch2 = {
|
|||
addFirstSpot: "新增第一個 Spot",
|
||||
},
|
||||
spotUi: {
|
||||
exactPinPublic: '精確位置會公開 — 請不要標記自己的家。',
|
||||
|
||||
claim: {
|
||||
areYouOwner: "這是你的 Spot 嗎?",
|
||||
privateDesc: "此 Spot 被標記為私人。如果你擁有或管理它,請認領——SkateSpots 版主確認所有權後,你即可接管該條目。",
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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';
|
||||
|
|
@ -680,15 +680,8 @@ 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)
|
||||
) {
|
||||
const saved = getLastMapView();
|
||||
if (saved) {
|
||||
return {
|
||||
center: [saved.lat, saved.lon] as [number, number],
|
||||
zoom: Math.min(18, Math.max(1, saved.zoom)),
|
||||
|
|
@ -696,9 +689,6 @@ export default function MapPage() {
|
|||
lon: saved.lon,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// fall through to the location/world default
|
||||
}
|
||||
const cached = getLastLocation();
|
||||
return cached
|
||||
? { center: [cached.lat, cached.lng] as [number, number], zoom: 12, lat: cached.lat, lon: cached.lng }
|
||||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue