import React, { useState, useEffect, useMemo, useRef, Suspense } from 'react' import { Map, Save, Layers, Box, ChevronDown, Check, Globe2 } from 'lucide-react' import { useTranslation } from '../../i18n' import { useSettingsStore } from '../../store/settingsStore' import { useToast } from '../shared/Toast' import CustomSelect from '../shared/CustomSelect' import { MapView } from '../Map/MapView' // The preview loads on demand, and paired with a single engine — a Leaflet-only // install pays for neither, and a GL install pays for one instead of both. import ErrorBoundary from '../shared/ErrorBoundary' import { GlMapPreviewMapbox, GlMapPreviewMaplibre } from '../Map/glLazy' import Section from './Section' import ToggleSwitch from './ToggleSwitch' import { withTileApiKey } from '../../utils/tileUrl' 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' }, ] // Tag → chip color mapping. Keeps the dropdown readable at a glance so a // user scanning the list can spot 3D / Satellite / Apple-like styles. const TAG_STYLES: Record = { '3D': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300', '2D': 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300', 'Satellite': 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300', 'Apple-like': 'bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300', 'Modern': 'bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300', 'Dark': 'bg-zinc-800 text-zinc-100 dark:bg-zinc-900 dark:text-zinc-300', 'Minimal': 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300', 'Hillshading': 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300', 'Terrain': 'bg-lime-100 text-lime-800 dark:bg-lime-900/40 dark:text-lime-300', 'Realistic': 'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300', 'Navigation': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300', 'Classic': 'bg-stone-100 text-stone-700 dark:bg-stone-800 dark:text-stone-300', 'Hybrid': 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300', 'No labels': 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300', 'OpenFreeMap': 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300', } function TagChip({ tag }: { tag: string }) { const cls = TAG_STYLES[tag] || 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300' return ( {tag} ) } function StyleDropdown({ value, provider, onChange }: { value: string; provider: GlMapProvider; onChange: (v: string) => void }) { const { t } = useTranslation() const [open, setOpen] = useState(false) const ref = useRef(null) const presets = getStylePresets(provider) useEffect(() => { if (!open) return const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) } document.addEventListener('mousedown', onDoc) return () => document.removeEventListener('mousedown', onDoc) }, [open]) const selected = presets.find(p => p.url === value) const placeholder = provider === 'maplibre-gl' ? t('settings.mapOpenFreeMapStylePlaceholder') : t('settings.mapStylePlaceholder') return (
{open && (
{presets.map(preset => { const isActive = preset.url === value return ( ) })}
)}
) } 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) // One chunk per engine — see components/Map/glLazy.tsx. const GlMapPreview = provider === 'maplibre-gl' ? GlMapPreviewMaplibre : GlMapPreviewMapbox 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() return (
{/* Provider picker — big cards so the choice is obvious */}

{t('settings.mapProviderHint')}

{/* Leaflet settings */} {provider === 'leaflet' && (
{ if (value) setMapTileUrl(value) }} placeholder={t('settings.mapTemplatePlaceholder.select')} options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))} size="sm" style={{ marginBottom: 8 }} /> ) => setMapTileUrl(e.target.value)} placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png" className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" />

{t('settings.mapDefaultHint')}

)} {/* Same deal as the Mapbox token: a managed install brings its own key. */} {provider === 'leaflet' && !managed && (
) => setCartoKey(e.target.value)} spellCheck={false} autoComplete="off" className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent" />

{t('settings.mapCartoKeyHint')}{' '} {t('settings.mapCartoKeyLink')}

{cartoNeedsKey && (

{t('settings.mapCartoKeyMissing')}

)}
)} {/* GL settings */} {provider !== 'leaflet' && (
{/* The token comes with the instance on a managed install, injected when the settings are read. A field here would only let somebody save a worse one. */} {provider === 'mapbox-gl' && !managed && (
setMapboxToken(e.target.value)} placeholder="pk.eyJ1Ijoi..." className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent" />

{t('settings.mapMapboxTokenHint')}{' '} {t('settings.mapMapboxTokenLink')}

)}
setMapboxStyle(e.target.value)} placeholder={defaultStyleForProvider(provider)} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-slate-400 focus:border-transparent" />

{provider === 'maplibre-gl' ? t('settings.mapOpenFreeMapStyleHint') : t('settings.mapStyleHint')}

{provider === 'mapbox-gl' && ( <>
{t('settings.map3dBuildings')}
{t('settings.map3dHint')}
{ if (supports3d) setMapbox3d(!mapbox3d) }} />
{t('settings.mapHighQuality')} {t('settings.mapExperimental')}
{t('settings.mapHighQualityHint')}{' '} {t('settings.mapHighQualityWarning')}
setMapboxQuality(!mapboxQuality)} />
{t('settings.mapTipLabel')} {t('settings.mapTip')}
)}
)}
{provider !== 'leaflet' ? ( /* A net of its own: the preview is the one place a user flips providers live, so it is the likeliest chunk to fail — and a broken preview must not take the rest of the settings tab with it. */ }> }> ) : ( // 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, // With the key on it, or the preview resolves the template as a // keyless CARTO one and quietly shows the app default instead of // the basemap being configured. The fields hold what the user is // editing rather than what useTileUrl already resolved, so the key // has to be put back on here. tileUrl: withTileApiKey(mapTileUrl, cartoKey), fitKey: null, dayOrderMap: [], leftWidth: 0, rightWidth: 0, hasInspector: false, }) )}
) }