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 },