mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
77162aa8d5
The switch a road tripper keeps asking for has never worked. OSRM's `exclude` is the only
avoidance TREK has, and the two public hosts it ships with answer it HTTP 400 on every
leg: their car profile is built without excludable classes, and their own Lua says why
("no memory for classes on demo server"). So "drive this without the tolls" produced
nothing at all on a default install, and the fallback inside the alternatives bar quietly
gave up after one refusal per host.
FOSSGIS runs a public Valhalla on the whole planet next to that OSRM, keyless, and there
avoidance is a runtime weighting rather than a profile that has to be rebuilt. This adds
it as a second engine asked only the questions the first cannot answer, and puts three
switches under the road trip settings that change the road itself rather than offering an
alternative beside it. Paris to Lyon measured 465.8 km / 297 min with tolls against
492.0 km / 454 min without; Hamburg to Copenhagen comes back ferry-free at 469.8 km,
which OSRM cannot produce at all — where a ferry IS the crossing it answers NoRoute
rather than driving round.
Four things the docs would have got wrong, all measured against the live instance:
- `format=osrm` does not carry `has_toll`. That flag is the entire reason for doing this,
and it exists only in the native answer — which is also 5.7x smaller, because the OSRM
shape drags every intersection along. So the adapter translates instead of borrowing a
familiar envelope.
- Without a `radius` Valhalla refuses a point it cannot bind to a road, where OSRM still
snaps one. Vienna's Stephansdom, in a pedestrian zone, answers error 442 until a search
radius is allowed. TREK is full of such places, so every location carries one.
- Ten locations is a hard cap and a road trip day is bundled into one request. A day over
it is split into runs overlapping by one point, which is exact rather than approximate:
a measured six-stop day came back within two metres and under a second of the same day
asked whole.
- Its drive times run 5.4 % above OSRM's over ten European legs and 36 % above on one, and
those times feed the arrival clocks of a day. So a day is answered by one engine or by
neither, never by both, and a piece that will not answer loses the whole day rather than
half of it — the schedule chains leg times blindly and would otherwise print a complete,
plausible, silently wrong timetable.
Because the weighting can fail, the honest surface is the answer rather than the request.
`use_tolls: 0` still returns a tolled road where there is no untolled crossing, so what
the road actually turned out to be is read back per leg, and a day that could not avoid
what it was asked to avoid says so on its own header instead of wearing a label the road
disproves. The switches disable themselves, with a reason, on an instance pointed at its
own OSRM: that operator chose against public hosts, and a second one they never asked for
would undo the choice.
Two things that would have broken quietly and did not:
- The route cache keys on `profile:unit:coords` and is shared with the day planner, the
studio and the booking geometry. Without the avoidance in the key, flipping a switch
returns the old road out of cache in under a millisecond — and worse, an avoiding answer
stored under the plain key hands those other callers a road nobody drives.
- `snapped` is OSRM-only and carries the dashed kerb-to-door spur from #2273. Valhalla
echoes the coordinates it was given and never says where it bound them, but the ends of
a leg's line are exactly those points: measured against Füssen, its first vertex matches
OSRM's snapped location to the digit.
The alternatives bar gets the same engine, which finally makes its "no motorway" and "no
tolls" offers appear on a default install, plus a ferry-free one. `sameRoad` now compares
geometry rather than numbers there, because two engines' distances for the same road sit
kilometres apart and the old 200 m tolerance called that a discovery.
Desktop only, like the whole road trip mode: nothing under client/src/mobile renders it.
568 lines
23 KiB
TypeScript
568 lines
23 KiB
TypeScript
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 (
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2 text-content-secondary">
|
|
{label}
|
|
</label>
|
|
{hint && <p className="text-xs mb-2 text-content-faint">{hint}</p>}
|
|
<div className="flex gap-3 flex-wrap">{children}</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function OptionButton({
|
|
active,
|
|
onClick,
|
|
children,
|
|
}: {
|
|
active: boolean
|
|
onClick: () => void
|
|
children: React.ReactNode
|
|
}) {
|
|
return (
|
|
<button type="button"
|
|
onClick={onClick}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: 8,
|
|
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
|
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
|
|
border: active ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
|
background: active ? 'var(--bg-hover)' : 'var(--bg-card)',
|
|
color: 'var(--text-primary)',
|
|
transition: 'all 0.15s',
|
|
}}
|
|
>
|
|
{children}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
export default function DefaultUserSettingsTab(): 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 [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<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(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) ? (
|
|
<button type="button"
|
|
onClick={() => reset(field)}
|
|
className="text-xs ml-2 text-content-faint underline"
|
|
style={{ background: 'none', border: 'none', cursor: 'pointer' }}
|
|
>
|
|
{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: String(new Date()),
|
|
}], [])
|
|
|
|
if (!loaded) {
|
|
return <p className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontStyle: 'italic', padding: 16 }}>Loading…</p>
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<p className="text-sm text-content-faint mb-4">
|
|
{t('admin.defaultSettings.description')}
|
|
</p>
|
|
|
|
{/* 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. */}
|
|
<div className="grid grid-cols-1 gap-x-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)] xl:items-start">
|
|
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
|
{/* Color Mode */}
|
|
<OptionRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
|
|
{([
|
|
{ value: 'light', label: t('settings.light') },
|
|
{ value: 'dark', label: t('settings.dark') },
|
|
{ value: 'auto', label: t('settings.auto') },
|
|
] as const).map(opt => (
|
|
<OptionButton
|
|
key={opt.value}
|
|
active={darkMode === opt.value || (opt.value === 'light' && darkMode === false) || (opt.value === 'dark' && darkMode === true)}
|
|
onClick={() => save({ dark_mode: opt.value })}
|
|
>
|
|
{opt.label}
|
|
</OptionButton>
|
|
))}
|
|
</OptionRow>
|
|
|
|
{/* Temperature */}
|
|
<OptionRow label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
|
|
{([
|
|
{ value: 'celsius', label: '°C Celsius' },
|
|
{ value: 'fahrenheit', label: '°F Fahrenheit' },
|
|
] as const).map(opt => (
|
|
<OptionButton
|
|
key={opt.value}
|
|
active={defaults.temperature_unit === opt.value}
|
|
onClick={() => save({ temperature_unit: opt.value })}
|
|
>
|
|
{opt.label}
|
|
</OptionButton>
|
|
))}
|
|
</OptionRow>
|
|
|
|
{/* Distance */}
|
|
<OptionRow label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
|
|
{([
|
|
{ value: 'metric', label: 'km Metric' },
|
|
{ value: 'imperial', label: 'mi Imperial' },
|
|
] as const).map(opt => (
|
|
<OptionButton
|
|
key={opt.value}
|
|
active={defaults.distance_unit === opt.value}
|
|
onClick={() => save({ distance_unit: opt.value })}
|
|
>
|
|
{opt.label}
|
|
</OptionButton>
|
|
))}
|
|
</OptionRow>
|
|
|
|
{/* Time Format */}
|
|
<OptionRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
|
|
{([
|
|
{ value: '24h', label: '24h (14:30)' },
|
|
{ value: '12h', label: '12h (2:30 PM)' },
|
|
] as const).map(opt => (
|
|
<OptionButton
|
|
key={opt.value}
|
|
active={defaults.time_format === opt.value}
|
|
onClick={() => save({ time_format: opt.value })}
|
|
>
|
|
{opt.label}
|
|
</OptionButton>
|
|
))}
|
|
</OptionRow>
|
|
|
|
{/* Default Currency */}
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
|
{t('settings.currency')} <ResetButton field="default_currency" />
|
|
</label>
|
|
<CustomSelect
|
|
value={defaults.default_currency || ''}
|
|
onChange={(value: string) => { 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 }}
|
|
/>
|
|
<p className="text-xs mt-1 text-content-faint">{t('settings.currencyHint')}</p>
|
|
</div>
|
|
|
|
{/* Blur Booking Codes */}
|
|
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
|
{([
|
|
{ value: true, label: t('settings.on') || 'On' },
|
|
{ value: false, label: t('settings.off') || 'Off' },
|
|
] as const).map(opt => (
|
|
<OptionButton
|
|
key={String(opt.value)}
|
|
active={defaults.blur_booking_codes === opt.value}
|
|
onClick={() => save({ blur_booking_codes: opt.value })}
|
|
>
|
|
{opt.label}
|
|
</OptionButton>
|
|
))}
|
|
</OptionRow>
|
|
</Section>
|
|
|
|
<Section title={t('settings.map')} icon={MapIcon}>
|
|
{/* Map Tile URL */}
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
|
{t('settings.mapTemplate')}
|
|
<ResetButton field="map_tile_url" />
|
|
</label>
|
|
<CustomSelect
|
|
value={mapTileUrl}
|
|
onChange={(value: string) => { 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 }}
|
|
/>
|
|
<input
|
|
type="text"
|
|
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"
|
|
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"
|
|
/>
|
|
<p className="text-xs mt-1 text-content-faint">{t('settings.mapDefaultHint')}</p>
|
|
{/* 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 && (
|
|
<div style={{ marginTop: 14 }}>
|
|
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
|
{t('admin.defaultSettings.cartoKey')}
|
|
<ResetButton field="carto_api_key" />
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={cartoKey}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => 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"
|
|
/>
|
|
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.cartoKeyHint')}</p>
|
|
</div>
|
|
)}
|
|
{/* 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 && (
|
|
<div style={{ marginTop: 14 }}>
|
|
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
|
{t('settings.routingBase')}
|
|
<ResetButton field="routing_base_url" />
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={routingBase}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => 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"
|
|
/>
|
|
<p className="text-xs mt-1 text-content-faint">{t('settings.routingBaseHint')}</p>
|
|
</div>
|
|
)}
|
|
{/* 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 && (
|
|
<div style={{ marginTop: 14 }}>
|
|
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
|
{t('settings.valhallaBase')}
|
|
<ResetButton field="valhalla_base_url" />
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={valhallaBase}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => 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"
|
|
/>
|
|
<p className="text-xs mt-1 text-content-faint">{t('settings.valhallaBaseHint')}</p>
|
|
</div>
|
|
)}
|
|
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
|
{/* 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,
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Map provider / instance-wide Mapbox ───────────────────────── */}
|
|
<div style={{ borderTop: '1px solid var(--border-primary)', paddingTop: 20, marginTop: 4 }}>
|
|
<OptionRow
|
|
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
|
|
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 => (
|
|
<OptionButton
|
|
key={opt.value}
|
|
active={mapProvider === opt.value}
|
|
onClick={() => saveMapProvider(opt.value)}
|
|
>
|
|
{opt.label}
|
|
</OptionButton>
|
|
))}
|
|
</OptionRow>
|
|
|
|
{mapProvider !== 'leaflet' && (
|
|
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
|
|
{/* 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 && (
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
|
{t('admin.defaultSettings.mapboxToken')}
|
|
<ResetButton field="mapbox_access_token" />
|
|
</label>
|
|
<input
|
|
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"
|
|
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"
|
|
/>
|
|
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.mapboxTokenHint')}</p>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
|
{t('admin.defaultSettings.mapboxStyle')}
|
|
<ResetButton field={styleKey} />
|
|
</label>
|
|
<CustomSelect
|
|
value={mapboxStyle}
|
|
onChange={(value: string) => { 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 }}
|
|
/>
|
|
<input
|
|
type="text"
|
|
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)}
|
|
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"
|
|
/>
|
|
</div>
|
|
|
|
{mapProvider === 'mapbox-gl' && (
|
|
<>
|
|
<OptionRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
|
|
{([
|
|
{ value: true, label: t('settings.on') || 'On' },
|
|
{ value: false, label: t('settings.off') || 'Off' },
|
|
] as const).map(opt => (
|
|
<OptionButton key={String(opt.value)} active={(defaults.mapbox_3d_enabled ?? true) === opt.value} onClick={() => save({ mapbox_3d_enabled: opt.value })}>
|
|
{opt.label}
|
|
</OptionButton>
|
|
))}
|
|
</OptionRow>
|
|
|
|
<OptionRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
|
|
{([
|
|
{ value: true, label: t('settings.on') || 'On' },
|
|
{ value: false, label: t('settings.off') || 'Off' },
|
|
] as const).map(opt => (
|
|
<OptionButton key={String(opt.value)} active={(defaults.mapbox_quality_mode ?? false) === opt.value} onClick={() => save({ mapbox_quality_mode: opt.value })}>
|
|
{opt.label}
|
|
</OptionButton>
|
|
))}
|
|
</OptionRow>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Section>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|