import React, { useEffect, useMemo, 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 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' }, ] 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 [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 }[] = [ { 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 }, ] const presets = provider === 'leaflet' ? [] : getStylePresets(provider) const selectedPreset = presets.find((p) => p.url === mapboxStyle) const chevron = // One chunk per engine — see components/Map/glLazy.tsx. const GlMapPreview = provider === 'maplibre-gl' ? GlMapPreviewMaplibre : GlMapPreviewMapbox return ( {t('settings.mapProvider')}
{providers.map((p) => { const active = provider === p.id return ( ) })}
{t('settings.mapProviderHint')} {provider === 'leaflet' && ( <> {t('settings.mapTemplate')} p.url === mapTileUrl)?.name || t('settings.mapTemplatePlaceholder.select')} trailing={chevron} onClick={() => setPresetOpen(true)} /> ) => setMapTileUrl(e.target.value)} placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png" /> {t('settings.mapDefaultHint')} {/* A managed install brings its own key, same as the Mapbox token below. */} {!managed && ( <> {t('settings.mapCartoKey')} ) => setCartoKey(e.target.value)} spellCheck={false} autoComplete="off" /> {t('settings.mapCartoKeyHint')}{' '} {t('settings.mapCartoKeyLink')} {cartoNeedsKey && (

{t('settings.mapCartoKeyMissing')}

)} )} )} {provider !== 'leaflet' && ( <> {provider === 'mapbox-gl' && !managed && ( <> {t('settings.mapMapboxToken')} setMapboxToken(e.target.value)} placeholder="pk.eyJ1Ijoi..." /> {t('settings.mapMapboxTokenHint')}{' '} {t('settings.mapMapboxTokenLink')} )} {t('settings.mapStyle')} setStyleOpen(true)} /> setMapboxStyle(e.target.value)} placeholder={defaultStyleForProvider(provider)} /> {provider === 'maplibre-gl' ? t('settings.mapOpenFreeMapStyleHint') : t('settings.mapStyleHint')} {provider === 'mapbox-gl' && (
} /> } />

{t('settings.mapTipLabel')} {t('settings.mapTip')}

)} )}
{provider !== 'leaflet' ? ( /* See MapSettingsTab: the preview gets its own net. */ }> }> ) : ( // eslint-disable-next-line @typescript-eslint/no-explicit-any React.createElement(MapView as any, { places: previewPlaces, dayPlaces: [], route: null, routeSegments: null, selectedPlaceId: null, onMarkerClick: null, onMapClick: null, onMapContextMenu: null, tileUrl: mapTileUrl, fitKey: null, dayOrderMap: [], leftWidth: 0, rightWidth: 0, hasInspector: false, }) )}
{t('settings.saveMap')} setPresetOpen(false)} title={t('settings.mapTemplate')} value={mapTileUrl} onSelect={setMapTileUrl} options={MAP_PRESETS.map((p) => ({ value: p.url, label: p.name }))} /> setStyleOpen(false)} title={t('settings.mapStyle')} value={mapboxStyle} onSelect={setMapboxStyle} options={presets.map((p) => ({ value: p.url, label: ( {p.name} {(p.tags || []).map((tag) => ( {tag} ))} ), }))} />
) }