feat: persist map tile settings (provider/style/API keys) server-side on user record; unbake Thunderforest default key into backend config (THUNDERFOREST_DEFAULT_KEY) served via profile
This commit is contained in:
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '../../hooks/useAuth'
|
||||
import { useSyncStore, syncProgressPct } from '../../hooks/useSync'
|
||||
import { useHydrateMapSettings } from '../../hooks/useMapSettings'
|
||||
import UnitToggle from './UnitToggle'
|
||||
|
||||
const nav = [
|
||||
@@ -24,6 +25,9 @@ export default function Layout() {
|
||||
const [collapsed, setCollapsed] = useState(() => localStorage.getItem('navCollapsed') === '1')
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
|
||||
// Load the user's saved map tile preference from the server into the store.
|
||||
useHydrateMapSettings()
|
||||
|
||||
useEffect(() => {
|
||||
startPolling()
|
||||
return () => stopPolling()
|
||||
|
||||
@@ -1,56 +1,116 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useEffect } from 'react'
|
||||
import { create } from 'zustand'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '../utils/api'
|
||||
import { useAuthStore } from './useAuth'
|
||||
import { MAP_PROVIDERS, DEFAULT_MAP_SETTINGS, resolveTile } from '../utils/mapTiles'
|
||||
|
||||
// Global map tile preference (provider + style + per-provider API keys). Chosen
|
||||
// once on the Profile page and used by every Leaflet map in the app. Persisted
|
||||
// to localStorage so the choice sticks across reloads (mirrors useUnits). API
|
||||
// keys live client-side only — these are public, client-side tile keys.
|
||||
const KEY = 'mapSettings'
|
||||
// Global map tile preference (provider + style + per-provider API keys). The
|
||||
// source of truth is the user record on the server (so the choice and keys
|
||||
// follow the user across devices); localStorage is only a cache so maps render
|
||||
// correctly on first paint before the server hydrates. The built-in default
|
||||
// Thunderforest key is supplied by the server (not baked into this bundle).
|
||||
const CACHE = 'mapSettings'
|
||||
|
||||
function load() {
|
||||
function loadCache() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(KEY))
|
||||
if (saved && typeof saved === 'object') {
|
||||
return { ...DEFAULT_MAP_SETTINGS, ...saved, keys: { ...saved.keys } }
|
||||
const s = JSON.parse(localStorage.getItem(CACHE))
|
||||
if (s && typeof s === 'object') return s
|
||||
} catch { /* ignore malformed cache */ }
|
||||
return {}
|
||||
}
|
||||
|
||||
function writeCache(s) {
|
||||
localStorage.setItem(CACHE, JSON.stringify({
|
||||
provider: s.provider,
|
||||
style: s.style,
|
||||
keys: s.keys,
|
||||
defaultThunderforestKey: s.defaultThunderforestKey,
|
||||
}))
|
||||
}
|
||||
|
||||
// First style id for a provider, used when switching provider or validating.
|
||||
const firstStyle = (provider) => Object.keys(MAP_PROVIDERS[provider]?.styles || {})[0]
|
||||
|
||||
let saveTimer = null
|
||||
const cache0 = loadCache()
|
||||
|
||||
export const useMapSettingsStore = create((set, get) => ({
|
||||
provider: cache0.provider || DEFAULT_MAP_SETTINGS.provider,
|
||||
style: cache0.style || DEFAULT_MAP_SETTINGS.style,
|
||||
keys: cache0.keys || {},
|
||||
defaultThunderforestKey: cache0.defaultThunderforestKey || '',
|
||||
hydrated: false,
|
||||
|
||||
// Populate from the server profile. Only marks the store ready to persist
|
||||
// after this runs, so we never clobber the server with stale cache values.
|
||||
hydrate: (server, defaultKey) => set((s) => {
|
||||
const ms = server || {}
|
||||
const provider = MAP_PROVIDERS[ms.provider] ? ms.provider : s.provider
|
||||
const style = MAP_PROVIDERS[provider]?.styles[ms.style] ? ms.style
|
||||
: (MAP_PROVIDERS[provider]?.styles[s.style] ? s.style : firstStyle(provider))
|
||||
const next = {
|
||||
...s,
|
||||
provider,
|
||||
style,
|
||||
keys: ms.keys || s.keys || {},
|
||||
defaultThunderforestKey: defaultKey || s.defaultThunderforestKey || '',
|
||||
hydrated: true,
|
||||
}
|
||||
} catch { /* ignore malformed storage */ }
|
||||
return { ...DEFAULT_MAP_SETTINGS, keys: {} }
|
||||
}
|
||||
writeCache(next)
|
||||
return next
|
||||
}),
|
||||
|
||||
function persist(s) {
|
||||
localStorage.setItem(KEY, JSON.stringify({ provider: s.provider, style: s.style, keys: s.keys }))
|
||||
}
|
||||
// Debounced persist of the full settings to the user record.
|
||||
_save: () => {
|
||||
const s = get()
|
||||
if (!s.hydrated) return
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => {
|
||||
api.put('/profile/map-settings', { provider: s.provider, style: s.style, keys: s.keys })
|
||||
.catch(() => { /* non-fatal; cache keeps the local choice */ })
|
||||
}, 600)
|
||||
},
|
||||
|
||||
export const useMapSettingsStore = create((set) => ({
|
||||
...load(),
|
||||
setProvider: (provider) => set((s) => {
|
||||
// Reset the style to the provider's first available style.
|
||||
const styleId = Object.keys(MAP_PROVIDERS[provider]?.styles || {})[0]
|
||||
const next = { ...s, provider, style: styleId }
|
||||
persist(next)
|
||||
return next
|
||||
}),
|
||||
setStyle: (style) => set((s) => {
|
||||
const next = { ...s, style }
|
||||
persist(next)
|
||||
return next
|
||||
}),
|
||||
setKey: (provider, value) => set((s) => {
|
||||
const next = { ...s, keys: { ...s.keys, [provider]: value } }
|
||||
persist(next)
|
||||
return next
|
||||
}),
|
||||
setProvider: (provider) => {
|
||||
set((s) => { const next = { ...s, provider, style: firstStyle(provider) }; writeCache(next); return next })
|
||||
get()._save()
|
||||
},
|
||||
setStyle: (style) => {
|
||||
set((s) => { const next = { ...s, style }; writeCache(next); return next })
|
||||
get()._save()
|
||||
},
|
||||
setKey: (provider, value) => {
|
||||
set((s) => { const next = { ...s, keys: { ...s.keys, [provider]: value } }; writeCache(next); return next })
|
||||
get()._save()
|
||||
},
|
||||
}))
|
||||
|
||||
// Hydrate the store from the server profile once authenticated. Call once near
|
||||
// the app root (Layout). Reuses the shared ['profile'] query cache.
|
||||
export function useHydrateMapSettings() {
|
||||
const token = useAuthStore((s) => s.token)
|
||||
const hydrate = useMapSettingsStore((s) => s.hydrate)
|
||||
const { data } = useQuery({
|
||||
queryKey: ['profile'],
|
||||
queryFn: () => api.get('/profile/').then((r) => r.data),
|
||||
enabled: !!token,
|
||||
})
|
||||
useEffect(() => {
|
||||
if (data) hydrate(data.map_settings, data.thunderforest_default_key)
|
||||
}, [data])
|
||||
}
|
||||
|
||||
// Resolve the active tile layer config for a map. Pass satellite=true for an
|
||||
// imagery override (e.g. the per-activity satellite toggle).
|
||||
// imagery override (e.g. the per-activity satellite toggle). The Thunderforest
|
||||
// key falls back to the server-supplied default when the user hasn't set one.
|
||||
export function useResolvedTile(satellite = false) {
|
||||
const provider = useMapSettingsStore((s) => s.provider)
|
||||
const style = useMapSettingsStore((s) => s.style)
|
||||
const keys = useMapSettingsStore((s) => s.keys)
|
||||
return useMemo(
|
||||
() => resolveTile({ provider, style, keys }, { satellite }),
|
||||
[provider, style, keys, satellite],
|
||||
)
|
||||
const defaultTf = useMapSettingsStore((s) => s.defaultThunderforestKey)
|
||||
return useMemo(() => {
|
||||
const effectiveKeys = { ...keys, thunderforest: keys.thunderforest || defaultTf }
|
||||
return resolveTile({ provider, style, keys: effectiveKeys }, { satellite })
|
||||
}, [provider, style, keys, defaultTf, satellite])
|
||||
}
|
||||
|
||||
@@ -3,11 +3,6 @@
|
||||
// stored in the useMapSettings store; every Leaflet map resolves its base layer
|
||||
// through resolveTile() so a single setting drives the entire application.
|
||||
|
||||
// Built-in Thunderforest key (a public, client-side tile key) used when the user
|
||||
// hasn't supplied their own. Matches the historical RouteTileMap default so the
|
||||
// app keeps working out of the box, but it may be rate-limited under load.
|
||||
export const DEFAULT_THUNDERFOREST_KEY = '872984f587484873a74ea454662ffacb'
|
||||
|
||||
const OSM_ATTR = '© <a href="https://www.openstreetmap.org/copyright">OSM contributors</a>'
|
||||
|
||||
// Each provider lists its selectable styles (keyed by the id used in the tile
|
||||
@@ -34,7 +29,7 @@ export const MAP_PROVIDERS = {
|
||||
'spinal-map': { label: 'Spinal' },
|
||||
},
|
||||
build: (styleId, key) =>
|
||||
`https://{s}.tile.thunderforest.com/${styleId}/{z}/{x}/{y}.png?apikey=${key || DEFAULT_THUNDERFOREST_KEY}`,
|
||||
`https://{s}.tile.thunderforest.com/${styleId}/{z}/{x}/{y}.png?apikey=${key || ''}`,
|
||||
},
|
||||
|
||||
maptiler: {
|
||||
|
||||
Reference in New Issue
Block a user