mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
b401da2264
CARTO started stamping "API KEY REQUIRED" across every keyless basemap tile on 26.08.2026. #2060 added a key field as the immediate answer, but it leaves a self-hosted instance needing an email exchange with CARTO before its maps look right, and every default in the client still pointed at CARTO. Default to OpenFreeMap Positron instead: no key, no account, no request limit, commercial use allowed, attribution required. OpenFreeMap serves vector tiles only, so the Leaflet basemap is a MapLibre style document rather than an XYZ template. maplibre-gl-leaflet hangs a GL canvas into Leaflet's own tile pane, which leaves everything above it alone - markers, GeoJSON, clusters, plugin layers, the custom panes, and the click handling, since Leaflet's CSS already puts pointer-events:none on that layer. maplibre-gl still arrives only through a dynamic import, so no chunk that draws a map grows by a megabyte. Switched: the trip planner, the MapView fallback every preview goes through, the journey map, collections, the atlas, the shared trip page and the offline tile prefetch. OpenFreeMap Positron and Bright join the tile presets in settings, the admin defaults and both mobile screens. A CARTO template that is already saved keeps working when a key backs it - that stays a supported choice. Without a key it is not drawn at all: resolveTileUrl falls back to the default rather than to a watermark, so an instance that upgrades with one in its settings sees the new basemap immediately and needs no migration. The stored value is left as it is, so the Map settings tab still shows what was chosen and the warning underneath it explains why - one sentence appended, in all 23 locales. OpenFreeMap asks for a credit of its own; OpenStreetMap alone under its tiles is wrong and a licence problem. attributionForTile() derives it from the URL rather than from a flag at each call site, and the shared trip map, which carried no attribution at all, now gets one. Offline maps keep working and get cheaper. A vector style is four things instead of one, so sync/glPrefetcher.ts walks the style document, its sprite, the Latin glyph ranges of each font stack it uses, the TileJSON - the planet path is versioned, so guessing it answers 200 with an empty body - and then the .pbf tiles. Over the Berlin bounding box TREK computes, raster z10-16 is 2237 requests and 34.6 MB and gives up above z16; vector z10-14 is 177 requests and 32.1 MB and stays sharp above that, because MapLibre scales the z14 tile rather than asking for another. The Workbox rule for tiles.openfreemap.org moves to CacheFirst and shares the prefetcher's cache, so browsing tops a downloaded region up instead of filling a second copy beside it - a versioned tile path can never be stale. Clearing offline maps clears it too. The atlas loses its second raster layer. It existed to warm the HTTP cache of neighbouring zoom levels, which a vector basemap does not need; a second one would only cost a second WebGL context. OpenFreeMap has no label-free style, so its labels are switched off on the live map instead - they are all symbol layers, while borders and coastlines are line and fill - re-armed on style.load so a theme switch keeps them off. Theme changes call setStyle in place rather than rebuilding the layer: a swap per toggle would leak a WebGL context, and browsers cap those in the low teens.
448 lines
18 KiB
TypeScript
448 lines
18 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react'
|
|
import { ChevronDown, Settings2 } from 'lucide-react'
|
|
import { adminApi } from '../../../api/client'
|
|
import { useTranslation } from '../../../i18n'
|
|
import { useToast } from '../../../components/shared/Toast'
|
|
import { MapView } from '../../../components/Map/MapView'
|
|
import { SYMBOLS, currenciesWith } from '../../../components/Budget/BudgetPanel.constants'
|
|
import { getApiErrorMessage, type DistanceUnit, type Place } from '../../../types'
|
|
import { normalizeTileUrl } from '../../../utils/tileUrl'
|
|
import {
|
|
MAPBOX_DEFAULT_STYLE,
|
|
defaultStyleForProvider,
|
|
getStylePresets,
|
|
isOpenFreeMapStyle,
|
|
normalizeStyleForProvider,
|
|
styleSettingKey,
|
|
type GlMapProvider,
|
|
} from '../../../components/Map/glProviders'
|
|
import { useAuthStore } from '../../../store/authStore'
|
|
import MToggle from '../../components/MToggle'
|
|
import MSegmented from '../../components/MSegmented'
|
|
import { MAdminCard, MAdminCardHead, MAdminField, MAdminInput, MAdminRow } from './MAdminUi'
|
|
import { MSetSelectRow } from '../settings/MSettingsUi'
|
|
import MSetPickerSheet from '../settings/MSetPickerSheet'
|
|
|
|
const MAP_PRESETS = [
|
|
{ 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 Defaults = {
|
|
temperature_unit?: string
|
|
distance_unit?: DistanceUnit
|
|
dark_mode?: string | boolean
|
|
time_format?: string
|
|
default_currency?: string
|
|
blur_booking_codes?: boolean
|
|
map_tile_url?: string
|
|
carto_api_key?: string
|
|
map_provider?: string
|
|
mapbox_access_token?: string
|
|
mapbox_style?: string
|
|
maplibre_style?: string
|
|
mapbox_3d_enabled?: boolean
|
|
mapbox_quality_mode?: boolean
|
|
}
|
|
|
|
type MapProvider = 'leaflet' | GlMapProvider
|
|
|
|
function normalizeProvider(value: unknown): MapProvider {
|
|
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Mobile-native rebuild of the desktop DefaultUserSettingsTab. Identical data
|
|
// layer (adminApi defaults, per-change auto-save, reset-to-built-in) — only the
|
|
// presentation is relaid on the admin mobile design system.
|
|
export default function MAdminDefaultUserSettings(): React.ReactElement {
|
|
const { t } = useTranslation()
|
|
const toast = useToast()
|
|
const [defaults, setDefaults] = useState<Defaults>({})
|
|
const [loaded, setLoaded] = useState(false)
|
|
const [mapTileUrl, setMapTileUrl] = useState('')
|
|
const managed = useAuthStore((s) => s.managed)
|
|
const [mapboxToken, setMapboxToken] = useState('')
|
|
const [cartoKey, setCartoKey] = useState('')
|
|
const [mapboxStyle, setMapboxStyle] = useState('')
|
|
const [currencyOpen, setCurrencyOpen] = useState(false)
|
|
const [presetOpen, setPresetOpen] = useState(false)
|
|
const [styleOpen, setStyleOpen] = useState(false)
|
|
|
|
useEffect(() => {
|
|
adminApi.getDefaultUserSettings().then((data: Defaults) => {
|
|
const provider = normalizeProvider(data.map_provider)
|
|
setDefaults(data)
|
|
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
|
|
setMapboxToken(data.mapbox_access_token || '')
|
|
setCartoKey(data.carto_api_key || '')
|
|
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
|
|
setLoaded(true)
|
|
}).catch(() => setLoaded(true))
|
|
}, [])
|
|
|
|
const save = async (patch: Partial<Defaults>) => {
|
|
try {
|
|
const updated = await adminApi.updateDefaultUserSettings(patch as Record<string, unknown>)
|
|
setDefaults(updated)
|
|
toast.success(t('admin.defaultSettings.saved'))
|
|
} catch (err: unknown) {
|
|
toast.error(getApiErrorMessage(err, t('common.error')))
|
|
}
|
|
}
|
|
|
|
const reset = async (key: keyof Defaults) => {
|
|
try {
|
|
const updated = await adminApi.updateDefaultUserSettings({ [key]: null })
|
|
setDefaults(updated)
|
|
if (key === 'map_tile_url') setMapTileUrl('')
|
|
if (key === 'mapbox_access_token') setMapboxToken('')
|
|
if (key === 'carto_api_key') setCartoKey('')
|
|
if (key === 'mapbox_style' || key === 'maplibre_style') {
|
|
const provider = normalizeProvider(defaults.map_provider)
|
|
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
|
|
}
|
|
toast.success(t('admin.defaultSettings.reset'))
|
|
} catch (err: unknown) {
|
|
toast.error(getApiErrorMessage(err, t('common.error')))
|
|
}
|
|
}
|
|
|
|
const isSet = (key: keyof Defaults) => defaults[key] !== undefined
|
|
|
|
const ResetButton = ({ field }: { field: keyof Defaults }) =>
|
|
isSet(field) ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => reset(field)}
|
|
className="ml-2 font-geist text-[0.625rem] font-medium text-m-faint underline"
|
|
>
|
|
{t('admin.defaultSettings.resetToBuiltIn')}
|
|
</button>
|
|
) : null
|
|
|
|
const mapPreviewPlaces = useMemo((): Place[] => [{
|
|
id: 1,
|
|
trip_id: 1,
|
|
name: 'Preview center',
|
|
description: null,
|
|
notes: null,
|
|
lat: 48.8566,
|
|
lng: 2.3522,
|
|
address: null,
|
|
category_id: null,
|
|
price: null,
|
|
currency: null,
|
|
image_url: null,
|
|
google_place_id: null,
|
|
osm_id: null,
|
|
route_geometry: null,
|
|
place_time: null,
|
|
end_time: null,
|
|
duration_minutes: null,
|
|
transport_mode: null,
|
|
website: null,
|
|
phone: null,
|
|
created_at: new Date().toISOString(),
|
|
}], [])
|
|
|
|
if (!loaded) {
|
|
return (
|
|
<MAdminCard>
|
|
<p className="font-geist text-[0.75rem] italic text-m-faint">Loading…</p>
|
|
</MAdminCard>
|
|
)
|
|
}
|
|
|
|
const darkMode = defaults.dark_mode
|
|
const mapProvider = normalizeProvider(defaults.map_provider)
|
|
const glStylePresets = mapProvider === 'leaflet' ? [] : getStylePresets(mapProvider)
|
|
const styleKey: keyof Defaults = mapProvider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style'
|
|
const saveMapProvider = (nextProvider: MapProvider) => {
|
|
const patch: Partial<Defaults> = { map_provider: nextProvider }
|
|
if (nextProvider !== 'leaflet') {
|
|
// Load + save the new provider's own style slot so the other provider's style is kept.
|
|
const slot = nextProvider === 'maplibre-gl' ? defaults.maplibre_style : defaults.mapbox_style
|
|
const nextStyle = styleForProvider(nextProvider, slot)
|
|
setMapboxStyle(nextStyle)
|
|
patch[styleSettingKey(nextProvider)] = nextStyle
|
|
}
|
|
save(patch)
|
|
}
|
|
|
|
// No active value when the setting is unset → segmented shows no pill, matching
|
|
// the desktop tab (which only highlights a button once a default is chosen).
|
|
const colorModeValue =
|
|
darkMode === 'dark' || darkMode === true
|
|
? 'dark'
|
|
: darkMode === 'light' || darkMode === false
|
|
? 'light'
|
|
: darkMode === 'auto'
|
|
? 'auto'
|
|
: ''
|
|
|
|
const modeOptions: { value: string; label: React.ReactNode }[] = [
|
|
{ value: 'light', label: t('settings.light') },
|
|
{ value: 'dark', label: t('settings.dark') },
|
|
{ value: 'auto', label: t('settings.auto') },
|
|
]
|
|
const tempOptions: { value: string; label: React.ReactNode }[] = [
|
|
{ value: 'celsius', label: '°C Celsius' },
|
|
{ value: 'fahrenheit', label: '°F Fahrenheit' },
|
|
]
|
|
const distanceOptions: { value: string; label: React.ReactNode }[] = [
|
|
{ value: 'metric', label: 'km Metric' },
|
|
{ value: 'imperial', label: 'mi Imperial' },
|
|
]
|
|
const timeOptions: { value: string; label: React.ReactNode }[] = [
|
|
{ value: '24h', label: '24h (14:30)' },
|
|
{ value: '12h', label: '12h (2:30 PM)' },
|
|
]
|
|
const providerOptions: { value: string; label: React.ReactNode }[] = [
|
|
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
|
|
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
|
|
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
|
|
]
|
|
|
|
const currencyLabel = defaults.default_currency
|
|
? (SYMBOLS[defaults.default_currency] ? `${defaults.default_currency} ${SYMBOLS[defaults.default_currency]}` : defaults.default_currency)
|
|
: t('settings.currency')
|
|
const tilePresetLabel = MAP_PRESETS.find((p) => p.url === mapTileUrl)?.name || t('settings.mapTemplatePlaceholder.select')
|
|
const stylePresetLabel = glStylePresets.find((p) => p.url === mapboxStyle)?.name || t('admin.defaultSettings.mapboxStylePlaceholder')
|
|
const chevron = <ChevronDown size={13} strokeWidth={2} className="flex-none text-m-faint" />
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* Display + regional defaults */}
|
|
<MAdminCard>
|
|
<MAdminCardHead title={t('admin.defaultSettings.title')} hint={t('admin.defaultSettings.description')} />
|
|
|
|
<div className="mt-2 space-y-[14px]">
|
|
{/* Color Mode */}
|
|
<MAdminField label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
|
|
<MSegmented value={colorModeValue} onChange={(v) => save({ dark_mode: v })} options={modeOptions} />
|
|
</MAdminField>
|
|
|
|
{/* Temperature */}
|
|
<MAdminField label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
|
|
<MSegmented value={defaults.temperature_unit || ''} onChange={(v) => save({ temperature_unit: v })} options={tempOptions} />
|
|
</MAdminField>
|
|
|
|
{/* Distance */}
|
|
<MAdminField label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
|
|
<MSegmented value={defaults.distance_unit || ''} onChange={(v) => save({ distance_unit: v as DistanceUnit })} options={distanceOptions} />
|
|
</MAdminField>
|
|
|
|
{/* Time Format */}
|
|
<MAdminField label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
|
|
<MSegmented value={defaults.time_format || ''} onChange={(v) => save({ time_format: v })} options={timeOptions} />
|
|
</MAdminField>
|
|
|
|
{/* Default Currency */}
|
|
<MAdminField
|
|
label={<>{t('settings.currency')} <ResetButton field="default_currency" /></>}
|
|
hint={t('settings.currencyHint')}
|
|
>
|
|
<MSetSelectRow label={currencyLabel} trailing={chevron} onClick={() => setCurrencyOpen(true)} />
|
|
</MAdminField>
|
|
|
|
{/* Blur Booking Codes */}
|
|
<MAdminRow
|
|
first
|
|
title={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}
|
|
trailing={
|
|
<MToggle
|
|
checked={defaults.blur_booking_codes === true}
|
|
ariaLabel={t('settings.blurBookingCodes')}
|
|
onChange={(v) => save({ blur_booking_codes: v })}
|
|
/>
|
|
}
|
|
/>
|
|
</div>
|
|
</MAdminCard>
|
|
|
|
{/* Map */}
|
|
<MAdminCard>
|
|
<div className="mb-1 flex items-center gap-2 text-[0.875rem] font-extrabold text-m-ink">
|
|
<Settings2 size={16} strokeWidth={2.2} className="flex-none" />
|
|
<span className="min-w-0 flex-1 truncate">{t('settings.mapTemplate')}</span>
|
|
</div>
|
|
|
|
<div className="mt-2 space-y-[14px]">
|
|
{/* Map Tile URL */}
|
|
<MAdminField
|
|
label={<>{t('settings.mapTemplate')} <ResetButton field="map_tile_url" /></>}
|
|
hint={t('settings.mapDefaultHint')}
|
|
>
|
|
<MSetSelectRow label={tilePresetLabel} trailing={chevron} onClick={() => setPresetOpen(true)} />
|
|
<MAdminInput
|
|
type="text"
|
|
className="mt-[6px]"
|
|
value={mapTileUrl}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
|
|
onBlur={() => save({ map_tile_url: mapTileUrl })}
|
|
placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
/>
|
|
</MAdminField>
|
|
|
|
{/* The key 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. */}
|
|
{!managed && (
|
|
<MAdminField
|
|
label={<>{t('admin.defaultSettings.cartoKey')} <ResetButton field="carto_api_key" /></>}
|
|
hint={t('admin.defaultSettings.cartoKeyHint')}
|
|
>
|
|
<MAdminInput
|
|
type="text"
|
|
value={cartoKey}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCartoKey(e.target.value)}
|
|
onBlur={() => save({ carto_api_key: cartoKey })}
|
|
spellCheck={false}
|
|
autoComplete="off"
|
|
/>
|
|
</MAdminField>
|
|
)}
|
|
|
|
{/* Live tile preview */}
|
|
<div className="relative h-[200px] w-full overflow-hidden rounded-xl">
|
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
|
{React.createElement(MapView as any, {
|
|
places: mapPreviewPlaces,
|
|
dayPlaces: [],
|
|
route: null,
|
|
routeSegments: null,
|
|
selectedPlaceId: null,
|
|
onMarkerClick: null,
|
|
onMapClick: null,
|
|
onMapContextMenu: null,
|
|
center: [48.8566, 2.3522],
|
|
zoom: 10,
|
|
tileUrl: mapTileUrl,
|
|
fitKey: null,
|
|
dayOrderMap: [],
|
|
leftWidth: 0,
|
|
rightWidth: 0,
|
|
hasInspector: false,
|
|
})}
|
|
</div>
|
|
|
|
{/* Map provider */}
|
|
<div className="border-t border-[color:var(--m-rowbr)] pt-[14px]">
|
|
<MAdminField
|
|
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
|
|
hint={t('admin.defaultSettings.mapProviderHint')}
|
|
>
|
|
<MSegmented value={mapProvider} onChange={(v) => saveMapProvider(v as MapProvider)} options={providerOptions} />
|
|
</MAdminField>
|
|
</div>
|
|
|
|
{mapProvider !== 'leaflet' && (
|
|
<div className="space-y-[14px]">
|
|
{mapProvider === 'mapbox-gl' && !managed && (
|
|
<MAdminField
|
|
label={<>{t('admin.defaultSettings.mapboxToken')} <ResetButton field="mapbox_access_token" /></>}
|
|
hint={t('admin.defaultSettings.mapboxTokenHint')}
|
|
>
|
|
<MAdminInput
|
|
type="text"
|
|
value={mapboxToken}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxToken(e.target.value)}
|
|
onBlur={() => save({ mapbox_access_token: mapboxToken })}
|
|
placeholder="pk.eyJ…"
|
|
spellCheck={false}
|
|
autoComplete="off"
|
|
/>
|
|
</MAdminField>
|
|
)}
|
|
|
|
<MAdminField label={<>{t('admin.defaultSettings.mapboxStyle')} <ResetButton field={styleKey} /></>}>
|
|
<MSetSelectRow label={stylePresetLabel} trailing={chevron} onClick={() => setStyleOpen(true)} />
|
|
<MAdminInput
|
|
type="text"
|
|
className="mt-[6px]"
|
|
value={mapboxStyle}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
|
|
onBlur={() => {
|
|
const nextStyle = normalizeStyleForProvider(mapProvider, mapboxStyle)
|
|
setMapboxStyle(nextStyle)
|
|
save({ [styleKey]: nextStyle })
|
|
}}
|
|
placeholder={defaultStyleForProvider(mapProvider)}
|
|
/>
|
|
</MAdminField>
|
|
|
|
{mapProvider === 'mapbox-gl' && (
|
|
<div>
|
|
<MAdminRow
|
|
first
|
|
title={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}
|
|
trailing={
|
|
<MToggle
|
|
checked={defaults.mapbox_3d_enabled ?? true}
|
|
ariaLabel={t('admin.defaultSettings.mapbox3d')}
|
|
onChange={(v) => save({ mapbox_3d_enabled: v })}
|
|
/>
|
|
}
|
|
/>
|
|
<MAdminRow
|
|
title={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}
|
|
trailing={
|
|
<MToggle
|
|
checked={defaults.mapbox_quality_mode ?? false}
|
|
ariaLabel={t('admin.defaultSettings.mapboxQuality')}
|
|
onChange={(v) => save({ mapbox_quality_mode: v })}
|
|
/>
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</MAdminCard>
|
|
|
|
{/* Pickers */}
|
|
<MSetPickerSheet
|
|
open={currencyOpen}
|
|
onClose={() => setCurrencyOpen(false)}
|
|
title={t('settings.currency')}
|
|
value={defaults.default_currency || ''}
|
|
onSelect={(value) => { if (value) save({ default_currency: value }) }}
|
|
options={currenciesWith(defaults.default_currency).map((c) => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
|
|
/>
|
|
|
|
<MSetPickerSheet
|
|
open={presetOpen}
|
|
onClose={() => setPresetOpen(false)}
|
|
title={t('settings.mapTemplate')}
|
|
value={mapTileUrl}
|
|
onSelect={(value) => { if (value) { setMapTileUrl(value); save({ map_tile_url: value }) } }}
|
|
options={MAP_PRESETS.map((p) => ({ value: p.url, label: p.name }))}
|
|
/>
|
|
|
|
<MSetPickerSheet
|
|
open={styleOpen}
|
|
onClose={() => setStyleOpen(false)}
|
|
title={t('admin.defaultSettings.mapboxStyle')}
|
|
value={mapboxStyle}
|
|
onSelect={(value) => { if (value) { setMapboxStyle(value); save({ [styleKey]: value }) } }}
|
|
options={glStylePresets.map((p) => ({ value: p.url, label: p.name }))}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|