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
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 7s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 9s

This commit is contained in:
2026-06-23 12:37:27 +01:00
parent ffe285b7ba
commit 3c97595093
8 changed files with 157 additions and 49 deletions
+4
View File
@@ -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()
+100 -40
View File
@@ -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])
}
+1 -6
View File
@@ -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: {