import React, { useEffect, useMemo, useState } from 'react' import { Map as MapIcon, Settings2 } from 'lucide-react' import { adminApi } from '../../api/client' import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import Section from '../Settings/Section' import CustomSelect from '../shared/CustomSelect' import { MapView } from '../Map/MapView' import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants' import type { DistanceUnit, Place } from '../../types' import { normalizeTileUrl, withTileApiKey } from '../../utils/tileUrl' import { MAPBOX_DEFAULT_STYLE, defaultStyleForProvider, getStylePresets, isOpenFreeMapStyle, normalizeStyleForProvider, styleSettingKey, type GlMapProvider, } from '../Map/glProviders' import { useAuthStore } from '../../store/authStore' 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 routing_base_url?: string valhalla_base_url?: 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' } /** Only the GL providers keep a style — Leaflet is handled by its callers. */ function styleForProvider(provider: GlMapProvider, style?: string | null): string { if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE return normalizeStyleForProvider(provider, style) } function OptionRow({ label, hint, children, }: { label: React.ReactNode hint?: string children: React.ReactNode }) { return (
{hint &&

{hint}

}
{children}
) } function OptionButton({ active, onClick, children, }: { active: boolean onClick: () => void children: React.ReactNode }) { return ( ) } export default function DefaultUserSettingsTab(): React.ReactElement { const { t } = useTranslation() const toast = useToast() const [defaults, setDefaults] = useState({}) const [loaded, setLoaded] = useState(false) const [mapTileUrl, setMapTileUrl] = useState('') const managed = useAuthStore((s) => s.managed) const [mapboxToken, setMapboxToken] = useState('') const [cartoKey, setCartoKey] = useState('') const [routingBase, setRoutingBase] = useState('') const [valhallaBase, setValhallaBase] = useState('') const [mapboxStyle, setMapboxStyle] = useState('') 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 || '') setRoutingBase(data.routing_base_url || '') setValhallaBase(data.valhalla_base_url || '') 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) => { try { const updated = await adminApi.updateDefaultUserSettings(patch as Record) setDefaults(updated) toast.success(t('admin.defaultSettings.saved')) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 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 === 'routing_base_url') setRoutingBase('') if (key === 'valhalla_base_url') setValhallaBase('') 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(err instanceof Error ? err.message : t('common.error')) } } const isSet = (key: keyof Defaults) => defaults[key] !== undefined const ResetButton = ({ field }: { field: keyof Defaults }) => isSet(field) ? ( ) : 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: String(new Date()), }], []) if (!loaded) { return

Loading…

} 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 = { 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) } return (

{t('admin.defaultSettings.description')}

{/* Two columns from xl up, the same idea as the Settings tab: as one flat list the map fields sat a screen below the units while the right half of the page stayed empty. Grouped by subject rather than by height — what a user ends up seeing on the left, everything that configures the map on the right. The columns are deliberately uneven: the left one never needs more than the widest option row, while the right one holds four text fields whose values are 60-character tile URLs. Only the column gap is set, because Section carries its own bottom margin and a row gap would double it once the grid collapses. Two cards can sit straight in the grid; a third would need the explicit per-column stacks the Settings tab uses, or it leaves a hole. */}
{/* Color Mode */} {t('settings.colorMode')} }> {([ { value: 'light', label: t('settings.light') }, { value: 'dark', label: t('settings.dark') }, { value: 'auto', label: t('settings.auto') }, ] as const).map(opt => ( save({ dark_mode: opt.value })} > {opt.label} ))} {/* Temperature */} {t('settings.temperature')} }> {([ { value: 'celsius', label: '°C Celsius' }, { value: 'fahrenheit', label: '°F Fahrenheit' }, ] as const).map(opt => ( save({ temperature_unit: opt.value })} > {opt.label} ))} {/* Distance */} {t('settings.distance')} }> {([ { value: 'metric', label: 'km Metric' }, { value: 'imperial', label: 'mi Imperial' }, ] as const).map(opt => ( save({ distance_unit: opt.value })} > {opt.label} ))} {/* Time Format */} {t('settings.timeFormat')} }> {([ { value: '24h', label: '24h (14:30)' }, { value: '12h', label: '12h (2:30 PM)' }, ] as const).map(opt => ( save({ time_format: opt.value })} > {opt.label} ))} {/* Default Currency */}
{ if (value) save({ default_currency: value }) }} placeholder={t('settings.currency')} searchable options={currenciesWith(defaults.default_currency).map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))} size="sm" style={{ maxWidth: 240 }} />

{t('settings.currencyHint')}

{/* Blur Booking Codes */} {t('settings.blurBookingCodes')} }> {([ { value: true, label: t('settings.on') || 'On' }, { value: false, label: t('settings.off') || 'Off' }, ] as const).map(opt => ( save({ blur_booking_codes: opt.value })} > {opt.label} ))}
{/* Map Tile URL */}
{ if (value) { setMapTileUrl(value); save({ map_tile_url: 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)} onBlur={() => save({ map_tile_url: mapTileUrl })} 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')}

{/* 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 && (
) => setCartoKey(e.target.value)} onBlur={() => save({ carto_api_key: cartoKey })} spellCheck={false} autoComplete="off" 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('admin.defaultSettings.cartoKeyHint')}

)} {/* Instance configuration, not a taste, which is why it sits here rather than in a user's own Map settings. The origin has to appear in the CSP `connect-src` the server emits at boot, and that list is built from this default — a value on a personal settings row is read by the route calculator and then refused by the browser, so the whole app stops routing with no error it could report. The write route refuses one from a non-admin for the same reason. */} {!managed && (
) => setRoutingBase(e.target.value)} onBlur={() => save({ routing_base_url: routingBase.trim() })} placeholder="https://osrm.example.org" spellCheck={false} autoComplete="off" 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.routingBaseHint')}

)} {/* The second engine, next to the first because it is the same kind of decision and carries the same restart caveat. Only ever asked to leave a road class out of one leg, which is the one question the OSRM above refuses. */} {!managed && (
) => setValhallaBase(e.target.value)} onBlur={() => save({ valhalla_base_url: valhallaBase.trim() })} placeholder="https://valhalla.example.org" spellCheck={false} autoComplete="off" 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.valhallaBaseHint')}

)}
{/* 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, // Same as the user-facing map tab: the field holds what is being // edited, so the key goes back on before the preview resolves it. tileUrl: withTileApiKey(mapTileUrl, cartoKey), fitKey: null, dayOrderMap: [], leftWidth: 0, rightWidth: 0, hasInspector: false, })}
{/* ── Map provider / instance-wide Mapbox ───────────────────────── */}
{t('admin.defaultSettings.mapProvider')} } hint={t('admin.defaultSettings.mapProviderHint')} > {([ { value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') }, { value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') }, { value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') }, ] as const).map(opt => ( saveMapProvider(opt.value)} > {opt.label} ))} {mapProvider !== '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. */} {mapProvider === 'mapbox-gl' && !managed && (
) => setMapboxToken(e.target.value)} onBlur={() => save({ mapbox_access_token: mapboxToken })} placeholder="pk.eyJ…" spellCheck={false} autoComplete="off" 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('admin.defaultSettings.mapboxTokenHint')}

)}
{ if (value) { setMapboxStyle(value); save({ [styleKey]: value }) } }} placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')} options={glStylePresets.map(p => ({ value: p.url, label: p.name }))} size="sm" style={{ marginBottom: 8 }} /> ) => setMapboxStyle(e.target.value)} onBlur={() => { const nextStyle = normalizeStyleForProvider(mapProvider, mapboxStyle) setMapboxStyle(nextStyle) save({ [styleKey]: nextStyle }) }} placeholder={defaultStyleForProvider(mapProvider)} 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" />
{mapProvider === 'mapbox-gl' && ( <> {t('admin.defaultSettings.mapbox3d')} }> {([ { value: true, label: t('settings.on') || 'On' }, { value: false, label: t('settings.off') || 'Off' }, ] as const).map(opt => ( save({ mapbox_3d_enabled: opt.value })}> {opt.label} ))} {t('admin.defaultSettings.mapboxQuality')} }> {([ { value: true, label: t('settings.on') || 'On' }, { value: false, label: t('settings.off') || 'Off' }, ] as const).map(opt => ( save({ mapbox_quality_mode: opt.value })}> {opt.label} ))} )}
)}
) }