From 0722a3b6ad59ecd6353917249e9f408753423917 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:15:40 +0200 Subject: [PATCH] refactor(settings): share the map settings form between desktop and phone The tile presets, the provider and style rules and the preview place move to mapSettingsModel, the form state and save to one useMapSettingsForm hook. The phone keeps showing the normalized style before the save lands, through an option. --- .../components/Settings/MapSettingsTab.tsx | 146 ++--------------- .../components/Settings/mapSettingsModel.ts | 95 +++++++++++ .../Settings/useMapSettingsForm.test.ts | 128 +++++++++++++++ .../components/Settings/useMapSettingsForm.ts | 110 +++++++++++++ .../mobile/screens/settings/MSettingsMap.tsx | 148 ++---------------- 5 files changed, 352 insertions(+), 275 deletions(-) create mode 100644 client/src/components/Settings/mapSettingsModel.ts create mode 100644 client/src/components/Settings/useMapSettingsForm.test.ts create mode 100644 client/src/components/Settings/useMapSettingsForm.ts diff --git a/client/src/components/Settings/MapSettingsTab.tsx b/client/src/components/Settings/MapSettingsTab.tsx index 8dc76086a..742080283 100644 --- a/client/src/components/Settings/MapSettingsTab.tsx +++ b/client/src/components/Settings/MapSettingsTab.tsx @@ -1,8 +1,6 @@ -import React, { useState, useEffect, useMemo, useRef, useId, Suspense, type ReactNode } from 'react' +import React, { useState, useEffect, useRef, useId, Suspense, type ReactNode } from 'react' import { Map, Save, Layers, Box, ChevronDown, Check, Globe2, type LucideIcon } from 'lucide-react' import { useTranslation } from '../../i18n' -import { useSettingsStore } from '../../store/settingsStore' -import { useToast } from '../shared/Toast' import CustomSelect from '../shared/CustomSelect' import { fs } from '../shared/DialogShell' import { EditorField, INPUT } from '../shared/dialogParts' @@ -16,41 +14,9 @@ import Section from './Section' import ToggleSwitch from './ToggleSwitch' import { SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill } from './settingsKit' import { withTileApiKey } from '../../utils/tileUrl' -import { AMAP_ROAD, AMAP_SATELLITE } from '../../constants/mapDefaults' -import type { Place } from '../../types' -import { - MAPBOX_DEFAULT_STYLE, - defaultStyleForProvider, - getStylePresets, - isOpenFreeMapStyle, - normalizeStyleForProvider, - type GlMapProvider, -} from '../Map/glProviders' -import { useAuthStore } from '../../store/authStore' - -interface MapPreset { - name: string - url: string -} - -const MAP_PRESETS: MapPreset[] = [ - { name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' }, - { name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' }, - // The app default, and a vector style rather than a {z}/{x}/{y} template: no - // key, no registration, no request limits. - { name: 'OpenFreeMap Positron', url: 'https://tiles.openfreemap.org/styles/positron' }, - { name: 'OpenFreeMap Bright', url: 'https://tiles.openfreemap.org/styles/bright' }, - // CARTO watermarks keyless tiles since 26.08.2026 and issues keys by mail, so - // these two need one; without it the map falls back to the default (#2054). - { name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' }, - { name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' }, - { name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' }, - // Amap (高德). GCJ-02 tiles — the map switches to a shifted projection for - // these so markers still land on the right street (see gcj02Crs.ts). The only - // basemap here that is genuinely good inside mainland China. - { name: '高德地图 (Amap)', url: AMAP_ROAD }, - { name: '高德卫星 (Amap Satellite)', url: AMAP_SATELLITE }, -] +import { defaultStyleForProvider, getStylePresets, type GlMapProvider } from '../Map/glProviders' +import { MAP_PRESETS, PREVIEW_CENTER, PREVIEW_ZOOM } from './mapSettingsModel' +import { useMapSettingsForm } from './useMapSettingsForm' /** A field holding a URL, token or key: the box look in Geist, so the characters read apart. */ const CODE_INPUT = `${INPUT} font-geist` @@ -161,115 +127,21 @@ function ProviderTile({ active, onClick, icon: Icon, name, subtitle, badge }: { ) } -type Provider = 'leaflet' | GlMapProvider - -function normalizeProvider(value: unknown): Provider { - return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet' -} - -function styleForProvider(provider: Provider, style?: string | null): string { - if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE - if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE - return normalizeStyleForProvider(provider, style) -} - -// Each GL provider has its own style slot, so toggling providers never clobbers the -// other one's style. Leaflet/Mapbox use mapbox_style; MapLibre uses maplibre_style. -function slotStyle(provider: Provider, s: { mapbox_style?: string; maplibre_style?: string }): string | undefined { - return provider === 'maplibre-gl' ? s.maplibre_style : s.mapbox_style -} - -/** - * Somewhere recognisable for the style preview to render. A city shows off label density, - * 3D buildings and satellite texture in a way open ocean cannot — it is not a user setting, - * and no map opens here: each map frames itself on its own places. - */ -const PREVIEW_CENTER: [number, number] = [48.8566, 2.3522] -const PREVIEW_ZOOM = 16 - export default function MapSettingsTab(): React.ReactElement { - const { settings, updateSettings } = useSettingsStore() const { t } = useTranslation() - const toast = useToast() - const initialProvider = normalizeProvider(settings.map_provider) - const [saving, setSaving] = useState(false) - const [provider, setProvider] = useState(initialProvider) - const [mapTileUrl, setMapTileUrl] = useState(settings.map_tile_url || '') - const managed = useAuthStore((s) => s.managed) - const [mapboxToken, setMapboxToken] = useState(settings.mapbox_access_token || '') - const [cartoKey, setCartoKey] = useState(settings.carto_api_key || '') - const [mapboxStyle, setMapboxStyle] = useState(styleForProvider(initialProvider, slotStyle(initialProvider, settings))) - const [mapbox3d, setMapbox3d] = useState(settings.mapbox_3d_enabled !== false) - const [mapboxQuality, setMapboxQuality] = useState(settings.mapbox_quality_mode === true) + const { + managed, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken, + cartoKey, setCartoKey, cartoNeedsKey, mapboxStyle, setMapboxStyle, mapbox3d, setMapbox3d, + mapboxQuality, setMapboxQuality, previewPlaces, save: saveMapSettings, + } = useMapSettingsForm() // One chunk per engine — see components/Map/glLazy.tsx. const GlMapPreview = provider === 'maplibre-gl' ? GlMapPreviewMaplibre : GlMapPreviewMapbox // Ties each eyebrow label to its field, so a click on the label focuses it. const fieldId = useId() - useEffect(() => { - const nextProvider = normalizeProvider(settings.map_provider) - setProvider(nextProvider) - setMapTileUrl(settings.map_tile_url || '') - setMapboxToken(settings.mapbox_access_token || '') - setCartoKey(settings.carto_api_key || '') - setMapboxStyle(styleForProvider(nextProvider, slotStyle(nextProvider, settings))) - setMapbox3d(settings.mapbox_3d_enabled !== false) - setMapboxQuality(settings.mapbox_quality_mode === true) - }, [settings]) - - const previewPlaces = useMemo((): Place[] => [{ - id: 1, - trip_id: 1, - name: 'Preview', - description: '', - lat: PREVIEW_CENTER[0], - lng: PREVIEW_CENTER[1], - address: '', - category_id: 0, - price: null, - image_url: null, - google_place_id: null, - osm_id: null, - route_geometry: null, - place_time: null, - end_time: null, - created_at: String(new Date()), - }], []) - - const saveMapSettings = async (): Promise => { - setSaving(true) - try { - const glStyle = provider === 'leaflet' ? mapboxStyle : normalizeStyleForProvider(provider, mapboxStyle) - // Save into the active provider's own slot so the other provider's style survives. - const stylePatch = provider === 'maplibre-gl' ? { maplibre_style: glStyle } : { mapbox_style: glStyle } - await updateSettings({ - map_provider: provider, - map_tile_url: mapTileUrl, - mapbox_access_token: mapboxToken, - carto_api_key: cartoKey, - ...stylePatch, - mapbox_3d_enabled: mapbox3d, - mapbox_quality_mode: mapboxQuality, - }) - // Only mirror the normalized style into the form once it is actually persisted. - setMapboxStyle(glStyle) - toast.success(t('settings.toast.mapSaved')) - } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : t('common.error')) - } finally { - setSaving(false) - } - } - // 3D is available on every style now — pure satellite uses the // mapbox-streets-v8 tileset as a fallback building source. const supports3d = true - const changeProvider = (nextProvider: Provider) => { - setProvider(nextProvider) - if (nextProvider !== 'leaflet') setMapboxStyle(styleForProvider(nextProvider, mapboxStyle)) - } - // Only CARTO burns a watermark into keyless tiles, so the nudge is scoped to its hosts. - const cartoNeedsKey = mapTileUrl.includes('basemaps.cartocdn.com') && !cartoKey.trim() const link = 'font-medium text-content-secondary underline decoration-edge underline-offset-2 hover:text-content' return ( diff --git a/client/src/components/Settings/mapSettingsModel.ts b/client/src/components/Settings/mapSettingsModel.ts new file mode 100644 index 000000000..1f1a9aac9 --- /dev/null +++ b/client/src/components/Settings/mapSettingsModel.ts @@ -0,0 +1,95 @@ +import { AMAP_ROAD, AMAP_SATELLITE } from '../../constants/mapDefaults'; +import type { Place } from '../../types'; +import { + MAPBOX_DEFAULT_STYLE, + isOpenFreeMapStyle, + normalizeStyleForProvider, + type GlMapProvider, +} from '../Map/glProviders'; + +/** + * The map settings both shells share: the tile presets, the provider and style rules, + * and the preview place. The user's map settings and the admin's defaults for users + * read the same presets, so the two can never offer a different list by accident. + */ + +export interface MapPreset { + name: string; + url: string; +} + +/** The tile presets an admin can make the default for every user. */ +export const DEFAULT_MAP_PRESETS: MapPreset[] = [ + { name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' }, + { name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' }, + // The app default, and a vector style rather than a {z}/{x}/{y} template: no + // key, no registration, no request limits. + { name: 'OpenFreeMap Positron', url: 'https://tiles.openfreemap.org/styles/positron' }, + { name: 'OpenFreeMap Bright', url: 'https://tiles.openfreemap.org/styles/bright' }, + // CARTO watermarks keyless tiles since 26.08.2026 and issues keys by mail, so + // these two need one; without it the map falls back to the default (#2054). + { name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' }, + { name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' }, + { name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' }, +]; + +/** The tile presets a user picks from in their own map settings. */ +export const MAP_PRESETS: MapPreset[] = [ + ...DEFAULT_MAP_PRESETS, + // Amap (高德). GCJ-02 tiles: the map switches to a shifted projection for + // these so markers still land on the right street (see gcj02Crs.ts). The only + // basemap here that is genuinely good inside mainland China. + { name: '高德地图 (Amap)', url: AMAP_ROAD }, + { name: '高德卫星 (Amap Satellite)', url: AMAP_SATELLITE }, +]; + +export type MapProvider = 'leaflet' | GlMapProvider; + +export function normalizeProvider(value: unknown): MapProvider { + return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'; +} + +export function styleForProvider(provider: MapProvider, style?: string | null): string { + if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE; + if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE; + return normalizeStyleForProvider(provider, style); +} + +// Each GL provider has its own style slot, so toggling providers never clobbers the +// other one's style. Leaflet/Mapbox use mapbox_style; MapLibre uses maplibre_style. +export function slotStyle( + provider: MapProvider, + s: { mapbox_style?: string; maplibre_style?: string } +): string | undefined { + return provider === 'maplibre-gl' ? s.maplibre_style : s.mapbox_style; +} + +/** + * Somewhere recognisable for the style preview to render. A city shows off label density, + * 3D buildings and satellite texture in a way open ocean cannot. It is not a user setting, + * and no map opens here: each map frames itself on its own places. + */ +export const PREVIEW_CENTER: [number, number] = [48.8566, 2.3522]; +export const PREVIEW_ZOOM = 16; + +/** The one place the user's map preview shows. */ +export function previewPlace(): Place { + return { + id: 1, + trip_id: 1, + name: 'Preview', + description: '', + lat: PREVIEW_CENTER[0], + lng: PREVIEW_CENTER[1], + address: '', + category_id: 0, + price: null, + image_url: null, + google_place_id: null, + osm_id: null, + route_geometry: null, + place_time: null, + end_time: null, + created_at: String(new Date()), + }; +} diff --git a/client/src/components/Settings/useMapSettingsForm.test.ts b/client/src/components/Settings/useMapSettingsForm.test.ts new file mode 100644 index 000000000..3ffd5204b --- /dev/null +++ b/client/src/components/Settings/useMapSettingsForm.test.ts @@ -0,0 +1,128 @@ +// FE-COMP-MAPFORM-001 onwards: the map settings form behind both settings shells. +import { act, renderHook } from '@testing-library/react'; + +import { buildSettings } from '../../../tests/helpers/factories'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { useAuthStore } from '../../store/authStore'; +import { useSettingsStore } from '../../store/settingsStore'; +import type { Settings } from '../../types'; +import { MAPBOX_DEFAULT_STYLE, OPENFREEMAP_DEFAULT_STYLE } from '../Map/glProviders'; +import { + DEFAULT_MAP_PRESETS, + MAP_PRESETS, + PREVIEW_CENTER, + normalizeProvider, + slotStyle, + styleForProvider, +} from './mapSettingsModel'; +import { useMapSettingsForm } from './useMapSettingsForm'; + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); +vi.mock('../shared/Toast', () => ({ useToast: () => toast })); +vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) })); + +const updateSettings = vi.fn(); + +function seed(over: Partial = {}) { + seedStore(useSettingsStore, { settings: buildSettings({ map_tile_url: '', ...over }), updateSettings }); +} + +beforeEach(() => { + resetAllStores(); + toast.success.mockReset(); + toast.error.mockReset(); + updateSettings.mockReset().mockResolvedValue(undefined); + seedStore(useAuthStore, { managed: false }); +}); + +describe('mapSettingsModel', () => { + it('FE-COMP-MAPFORM-001: the user list adds the two Amap presets to the admin list', () => { + expect(MAP_PRESETS.slice(0, DEFAULT_MAP_PRESETS.length)).toEqual(DEFAULT_MAP_PRESETS); + expect(DEFAULT_MAP_PRESETS).toHaveLength(7); + expect(MAP_PRESETS).toHaveLength(9); + }); + + it('FE-COMP-MAPFORM-002: provider and style rules', () => { + expect(normalizeProvider('mapbox-gl')).toBe('mapbox-gl'); + expect(normalizeProvider('maplibre-gl')).toBe('maplibre-gl'); + expect(normalizeProvider('bogus')).toBe('leaflet'); + expect(styleForProvider('leaflet', '')).toBe(MAPBOX_DEFAULT_STYLE); + expect(styleForProvider('leaflet', 'x')).toBe('x'); + expect(styleForProvider('mapbox-gl', 'https://tiles.openfreemap.org/styles/bright')).toBe(MAPBOX_DEFAULT_STYLE); + expect(styleForProvider('maplibre-gl', '')).toBe(OPENFREEMAP_DEFAULT_STYLE); + expect(slotStyle('maplibre-gl', { mapbox_style: 'a', maplibre_style: 'b' })).toBe('b'); + expect(slotStyle('mapbox-gl', { mapbox_style: 'a', maplibre_style: 'b' })).toBe('a'); + }); +}); + +describe('useMapSettingsForm', () => { + it('FE-COMP-MAPFORM-003: seeds the form from the settings store and builds one preview place', () => { + seed({ map_provider: 'maplibre-gl', maplibre_style: '', carto_api_key: 'ck', mapbox_quality_mode: true }); + const { result } = renderHook(() => useMapSettingsForm()); + expect(result.current.provider).toBe('maplibre-gl'); + expect(result.current.mapboxStyle).toBe(OPENFREEMAP_DEFAULT_STYLE); + expect(result.current.cartoKey).toBe('ck'); + expect(result.current.mapboxQuality).toBe(true); + expect(result.current.managed).toBe(false); + expect(result.current.previewPlaces).toHaveLength(1); + expect(result.current.previewPlaces[0]).toMatchObject({ lat: PREVIEW_CENTER[0], lng: PREVIEW_CENTER[1] }); + }); + + it('FE-COMP-MAPFORM-004: switching to a GL provider moves the style into that provider, leaflet keeps it', () => { + seed({ map_provider: 'mapbox-gl', mapbox_style: 'https://tiles.openfreemap.org/styles/bright' }); + const { result } = renderHook(() => useMapSettingsForm()); + expect(result.current.mapboxStyle).toBe(MAPBOX_DEFAULT_STYLE); + act(() => result.current.changeProvider('maplibre-gl')); + expect(result.current.mapboxStyle).toBe(OPENFREEMAP_DEFAULT_STYLE); + act(() => result.current.changeProvider('leaflet')); + expect(result.current.provider).toBe('leaflet'); + expect(result.current.mapboxStyle).toBe(OPENFREEMAP_DEFAULT_STYLE); + }); + + it('FE-COMP-MAPFORM-005: the CARTO nudge shows only for a CARTO template without a key', () => { + seed(); + const { result } = renderHook(() => useMapSettingsForm()); + act(() => result.current.setMapTileUrl(MAP_PRESETS[4].url)); + expect(result.current.cartoNeedsKey).toBe(true); + act(() => result.current.setCartoKey(' k ')); + expect(result.current.cartoNeedsKey).toBe(false); + }); + + it('FE-COMP-MAPFORM-006: save writes the active provider slot and toasts', async () => { + seed({ map_provider: 'maplibre-gl' }); + const { result } = renderHook(() => useMapSettingsForm()); + act(() => result.current.setMapboxStyle(' ')); + await act(() => result.current.save()); + expect(updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ map_provider: 'maplibre-gl', maplibre_style: OPENFREEMAP_DEFAULT_STYLE }) + ); + expect(updateSettings.mock.calls[0][0]).not.toHaveProperty('mapbox_style'); + expect(result.current.mapboxStyle).toBe(OPENFREEMAP_DEFAULT_STYLE); + expect(toast.success).toHaveBeenCalledWith('settings.toast.mapSaved'); + expect(result.current.saving).toBe(false); + }); + + it('FE-COMP-MAPFORM-007: on a failed save the desktop keeps the typed style, the phone shows the normalized one', async () => { + seed({ map_provider: 'mapbox-gl' }); + updateSettings.mockRejectedValue(new Error('nope')); + + const desktop = renderHook(() => useMapSettingsForm()); + act(() => desktop.result.current.setMapboxStyle('')); + await act(() => desktop.result.current.save()); + expect(desktop.result.current.mapboxStyle).toBe(''); + expect(toast.error).toHaveBeenCalledWith('nope'); + + const phone = renderHook(() => useMapSettingsForm({ mirrorStyleBeforeSave: true })); + act(() => phone.result.current.setMapboxStyle('')); + await act(() => phone.result.current.save()); + expect(phone.result.current.mapboxStyle).toBe(MAPBOX_DEFAULT_STYLE); + }); + + it('FE-COMP-MAPFORM-008: a non Error failure falls back to the generic message', async () => { + seed(); + updateSettings.mockRejectedValue('x'); + const { result } = renderHook(() => useMapSettingsForm()); + await act(() => result.current.save()); + expect(toast.error).toHaveBeenCalledWith('common.error'); + }); +}); diff --git a/client/src/components/Settings/useMapSettingsForm.ts b/client/src/components/Settings/useMapSettingsForm.ts new file mode 100644 index 000000000..810983da4 --- /dev/null +++ b/client/src/components/Settings/useMapSettingsForm.ts @@ -0,0 +1,110 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { useTranslation } from '../../i18n'; +import { useAuthStore } from '../../store/authStore'; +import { useSettingsStore } from '../../store/settingsStore'; +import type { Place } from '../../types'; +import { normalizeStyleForProvider } from '../Map/glProviders'; +import { useToast } from '../shared/Toast'; +import { type MapProvider, normalizeProvider, previewPlace, slotStyle, styleForProvider } from './mapSettingsModel'; + +export interface MapSettingsFormOptions { + /** + * The phone shows the normalized style in the field as soon as save is pressed; + * the desktop only once it is actually persisted. + */ + mirrorStyleBeforeSave?: boolean; +} + +/** + * The user's map settings form behind both settings shells (the desktop tab and the + * phone section render their own markup over this): the provider, the raster tiles + * and CARTO key, the Mapbox token, the GL style and the 3D and quality toggles, + * seeded from the settings store and saved back in one patch. + */ +export function useMapSettingsForm({ mirrorStyleBeforeSave = false }: MapSettingsFormOptions = {}) { + const { settings, updateSettings } = useSettingsStore(); + const { t } = useTranslation(); + const toast = useToast(); + const initialProvider = normalizeProvider(settings.map_provider); + const [saving, setSaving] = useState(false); + const [provider, setProvider] = useState(initialProvider); + const [mapTileUrl, setMapTileUrl] = useState(settings.map_tile_url || ''); + const managed = useAuthStore((s) => s.managed); + const [mapboxToken, setMapboxToken] = useState(settings.mapbox_access_token || ''); + const [cartoKey, setCartoKey] = useState(settings.carto_api_key || ''); + const [mapboxStyle, setMapboxStyle] = useState( + styleForProvider(initialProvider, slotStyle(initialProvider, settings)) + ); + const [mapbox3d, setMapbox3d] = useState(settings.mapbox_3d_enabled !== false); + const [mapboxQuality, setMapboxQuality] = useState(settings.mapbox_quality_mode === true); + + useEffect(() => { + const nextProvider = normalizeProvider(settings.map_provider); + setProvider(nextProvider); + setMapTileUrl(settings.map_tile_url || ''); + setMapboxToken(settings.mapbox_access_token || ''); + setCartoKey(settings.carto_api_key || ''); + setMapboxStyle(styleForProvider(nextProvider, slotStyle(nextProvider, settings))); + setMapbox3d(settings.mapbox_3d_enabled !== false); + setMapboxQuality(settings.mapbox_quality_mode === true); + }, [settings]); + + const previewPlaces = useMemo((): Place[] => [previewPlace()], []); + + const save = async (): Promise => { + setSaving(true); + try { + const glStyle = provider === 'leaflet' ? mapboxStyle : normalizeStyleForProvider(provider, mapboxStyle); + if (mirrorStyleBeforeSave) setMapboxStyle(glStyle); + // Save into the active provider's own slot so the other provider's style survives. + const stylePatch = provider === 'maplibre-gl' ? { maplibre_style: glStyle } : { mapbox_style: glStyle }; + await updateSettings({ + map_provider: provider, + map_tile_url: mapTileUrl, + mapbox_access_token: mapboxToken, + carto_api_key: cartoKey, + ...stylePatch, + mapbox_3d_enabled: mapbox3d, + mapbox_quality_mode: mapboxQuality, + }); + // Only mirror the normalized style into the form once it is actually persisted. + if (!mirrorStyleBeforeSave) setMapboxStyle(glStyle); + toast.success(t('settings.toast.mapSaved')); + } catch (err: unknown) { + toast.error(err instanceof Error ? err.message : t('common.error')); + } finally { + setSaving(false); + } + }; + + const changeProvider = (nextProvider: MapProvider) => { + setProvider(nextProvider); + if (nextProvider !== 'leaflet') setMapboxStyle(styleForProvider(nextProvider, mapboxStyle)); + }; + + // Only CARTO burns a watermark into keyless tiles, so the nudge is scoped to its hosts. + const cartoNeedsKey = mapTileUrl.includes('basemaps.cartocdn.com') && !cartoKey.trim(); + + return { + managed, + saving, + provider, + changeProvider, + mapTileUrl, + setMapTileUrl, + mapboxToken, + setMapboxToken, + cartoKey, + setCartoKey, + cartoNeedsKey, + mapboxStyle, + setMapboxStyle, + mapbox3d, + setMapbox3d, + mapboxQuality, + setMapboxQuality, + previewPlaces, + save, + }; +} diff --git a/client/src/mobile/screens/settings/MSettingsMap.tsx b/client/src/mobile/screens/settings/MSettingsMap.tsx index fe7b5e03e..0f8fc9917 100644 --- a/client/src/mobile/screens/settings/MSettingsMap.tsx +++ b/client/src/mobile/screens/settings/MSettingsMap.tsx @@ -1,161 +1,33 @@ -import React, { useEffect, useMemo, useState, Suspense } from 'react' +import React, { useState, Suspense } from 'react' import { Box, Check, ChevronDown, Globe2, Layers, Map, Save } from 'lucide-react' import { useTranslation } from '../../../i18n' -import { useSettingsStore } from '../../../store/settingsStore' -import { useAuthStore } from '../../../store/authStore' -import { useToast } from '../../../components/shared/Toast' import { MapView } from '../../../components/Map/MapView' // Same as the desktop map settings tab: on demand, and paired with one engine. import ErrorBoundary from '../../../components/shared/ErrorBoundary' import { GlMapPreviewMapbox, GlMapPreviewMaplibre } from '../../../components/Map/glLazy' -import type { Place } from '../../../types' -import { - MAPBOX_DEFAULT_STYLE, - defaultStyleForProvider, - getStylePresets, - isOpenFreeMapStyle, - normalizeStyleForProvider, - type GlMapProvider, -} from '../../../components/Map/glProviders' +import { defaultStyleForProvider, getStylePresets } from '../../../components/Map/glProviders' +import { MAP_PRESETS, PREVIEW_CENTER, PREVIEW_ZOOM, type MapProvider } from '../../../components/Settings/mapSettingsModel' +import { useMapSettingsForm } from '../../../components/Settings/useMapSettingsForm' import { withTileApiKey } from '../../../utils/tileUrl' -import { AMAP_ROAD, AMAP_SATELLITE } from '../../../constants/mapDefaults' import MToggle from '../../components/MToggle' import { MSetCard, MSetEyebrow, MSetSelectRow, MSetInput, MSetButton, MSetHint, MSetRow } from './MSettingsUi' import MSetPickerSheet from './MSetPickerSheet' -interface MapPreset { - name: string - url: string -} - -const MAP_PRESETS: MapPreset[] = [ - { name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' }, - { name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' }, - // The app default, and a vector style rather than a {z}/{x}/{y} template: no - // key, no registration, no request limits. - { name: 'OpenFreeMap Positron', url: 'https://tiles.openfreemap.org/styles/positron' }, - { name: 'OpenFreeMap Bright', url: 'https://tiles.openfreemap.org/styles/bright' }, - // CARTO watermarks keyless tiles since 26.08.2026 and issues keys by mail, so - // these two need one; without it the map falls back to the default (#2054). - { name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' }, - { name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' }, - { name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' }, - // Amap (高德). GCJ-02 tiles — the map switches to a shifted projection for - // these so markers still land on the right street (see gcj02Crs.ts). The only - // basemap here that is genuinely good inside mainland China. - { name: '高德地图 (Amap)', url: AMAP_ROAD }, - { name: '高德卫星 (Amap Satellite)', url: AMAP_SATELLITE }, -] - -type Provider = 'leaflet' | GlMapProvider - -function normalizeProvider(value: unknown): Provider { - return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet' -} - -function styleForProvider(provider: Provider, style?: string | null): string { - if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE - if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE - return normalizeStyleForProvider(provider, style) -} - -// Each GL provider has its own style slot so toggling providers never clobbers -// the other one's style (same rule as the desktop tab). -function slotStyle(provider: Provider, s: { mapbox_style?: string; maplibre_style?: string }): string | undefined { - return provider === 'maplibre-gl' ? s.maplibre_style : s.mapbox_style -} - -// A recognisable city so the preview shows label density / 3D / satellite texture. -const PREVIEW_CENTER: [number, number] = [48.8566, 2.3522] -const PREVIEW_ZOOM = 16 - /** * "Map" section — MapSettingsTab parity: provider, raster tiles, Mapbox token, * GL style, 3D + quality toggles and a live style preview. */ export default function MSettingsMap() { - const { settings, updateSettings } = useSettingsStore() const { t } = useTranslation() - const toast = useToast() - const initialProvider = normalizeProvider(settings.map_provider) - const [saving, setSaving] = useState(false) - const [provider, setProvider] = useState(initialProvider) - const [mapTileUrl, setMapTileUrl] = useState(settings.map_tile_url || '') - const managed = useAuthStore((s) => s.managed) - const [mapboxToken, setMapboxToken] = useState(settings.mapbox_access_token || '') - const [cartoKey, setCartoKey] = useState(settings.carto_api_key || '') - const [mapboxStyle, setMapboxStyle] = useState(styleForProvider(initialProvider, slotStyle(initialProvider, settings))) - const [mapbox3d, setMapbox3d] = useState(settings.mapbox_3d_enabled !== false) - const [mapboxQuality, setMapboxQuality] = useState(settings.mapbox_quality_mode === true) + const { + managed, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken, + cartoKey, setCartoKey, cartoNeedsKey, mapboxStyle, setMapboxStyle, mapbox3d, setMapbox3d, + mapboxQuality, setMapboxQuality, previewPlaces, save, + } = useMapSettingsForm({ mirrorStyleBeforeSave: true }) const [presetOpen, setPresetOpen] = useState(false) const [styleOpen, setStyleOpen] = useState(false) - useEffect(() => { - const nextProvider = normalizeProvider(settings.map_provider) - setProvider(nextProvider) - setMapTileUrl(settings.map_tile_url || '') - setMapboxToken(settings.mapbox_access_token || '') - setCartoKey(settings.carto_api_key || '') - setMapboxStyle(styleForProvider(nextProvider, slotStyle(nextProvider, settings))) - setMapbox3d(settings.mapbox_3d_enabled !== false) - setMapboxQuality(settings.mapbox_quality_mode === true) - }, [settings]) - - const previewPlaces = useMemo( - (): Place[] => [ - { - id: 1, - trip_id: 1, - name: 'Preview', - description: '', - lat: PREVIEW_CENTER[0], - lng: PREVIEW_CENTER[1], - address: '', - category_id: 0, - price: null, - image_url: null, - google_place_id: null, - osm_id: null, - route_geometry: null, - place_time: null, - end_time: null, - created_at: String(new Date()), - }, - ], - [], - ) - - const save = async (): Promise => { - setSaving(true) - try { - const glStyle = provider === 'leaflet' ? mapboxStyle : normalizeStyleForProvider(provider, mapboxStyle) - setMapboxStyle(glStyle) - const stylePatch = provider === 'maplibre-gl' ? { maplibre_style: glStyle } : { mapbox_style: glStyle } - await updateSettings({ - map_provider: provider, - map_tile_url: mapTileUrl, - mapbox_access_token: mapboxToken, - carto_api_key: cartoKey, - ...stylePatch, - mapbox_3d_enabled: mapbox3d, - mapbox_quality_mode: mapboxQuality, - }) - toast.success(t('settings.toast.mapSaved')) - } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : t('common.error')) - } finally { - setSaving(false) - } - } - - const changeProvider = (nextProvider: Provider) => { - setProvider(nextProvider) - if (nextProvider !== 'leaflet') setMapboxStyle(styleForProvider(nextProvider, mapboxStyle)) - } - // Only CARTO burns a watermark into keyless tiles, so the nudge is scoped to its hosts. - const cartoNeedsKey = mapTileUrl.includes('basemaps.cartocdn.com') && !cartoKey.trim() - - const providers: { id: Provider; name: string; sub: string; icon: typeof Layers }[] = [ + const providers: { id: MapProvider; name: string; sub: string; icon: typeof Layers }[] = [ { id: 'leaflet', name: 'Leaflet', sub: t('settings.mapLeafletSubtitle'), icon: Layers }, { id: 'mapbox-gl', name: 'Mapbox GL', sub: t('settings.mapMapboxSubtitle'), icon: Box }, { id: 'maplibre-gl', name: 'MapLibre GL', sub: t('settings.mapMapLibreSubtitle'), icon: Globe2 },